feat: prepare agen crates for publication
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
[package]
|
||||
name = "agen"
|
||||
description = "Provider-neutral orchestration for tool-using LLM applications"
|
||||
version = "0.2.1"
|
||||
edition.workspace = true
|
||||
rust-version = "1.86"
|
||||
license.workspace = true
|
||||
readme = "README.md"
|
||||
repository = "https://gitea.hareworks.net/Hare/yoi"
|
||||
homepage = "https://gitea.hareworks.net/Hare/yoi"
|
||||
documentation = "https://docs.rs/agen"
|
||||
keywords = ["llm", "agent", "tools", "streaming", "orchestration"]
|
||||
categories = ["api-bindings", "asynchronous"]
|
||||
include = ["src/**", "tests/**", "examples/*.rs", "docs/**", "README.md", "LICENSE"]
|
||||
autoexamples = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
codex = ["dep:chrono"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-util = "0.7"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "native-tls", "http2"] }
|
||||
eventsource-stream = "0.2"
|
||||
zstd = "0.13"
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
||||
agen-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
tempfile = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
trybuild = "1.0.116"
|
||||
wiremock = "0.6.5"
|
||||
|
||||
[[example]]
|
||||
name = "engine_cancel_demo"
|
||||
path = "examples/engine_cancel_demo.rs"
|
||||
|
||||
[[example]]
|
||||
name = "engine_cli"
|
||||
path = "examples/engine_cli.rs"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2026 Hare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,90 @@
|
||||
# agen
|
||||
|
||||
`agen` is a provider-neutral Rust engine for streaming LLM applications that use tools. It owns the turn loop, typed conversation history, provider wire-format adapters, tool execution, interceptors, usage accounting, and cache-aware state transitions.
|
||||
|
||||
> `agen` is pre-1.0. Public APIs may change between minor releases.
|
||||
|
||||
## Installation
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
agen = "0.2.1"
|
||||
```
|
||||
|
||||
The default feature set is intentionally empty. Enable the experimental Codex/ChatGPT authentication adapter when needed:
|
||||
|
||||
```toml
|
||||
agen = { version = "0.2.1", features = ["codex"] }
|
||||
```
|
||||
|
||||
`agen` requires Rust 1.86 or newer. The companion `agen-macros` package requires Rust 1.85 or newer.
|
||||
|
||||
## Quick start
|
||||
|
||||
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
|
||||
|
||||
```no_run
|
||||
use agen::{Engine, EngineError};
|
||||
use agen::llm_client::LlmClient;
|
||||
|
||||
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
||||
let output = Engine::new(client)
|
||||
.system_prompt("You are a concise assistant.")
|
||||
.run("Explain typed state in one sentence.")
|
||||
.await?;
|
||||
|
||||
let mut engine = output.engine;
|
||||
let _result = engine.run("Give a Rust example.").await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Declaring tools
|
||||
|
||||
The tool macros are re-exported by `agen`; applications do not need direct dependencies on `serde`, `schemars`, `serde_json`, or `async-trait` for generated code.
|
||||
|
||||
```rust
|
||||
use agen::tool_registry;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tools;
|
||||
|
||||
#[tool_registry]
|
||||
impl Tools {
|
||||
/// Returns the supplied text.
|
||||
#[tool]
|
||||
async fn echo(
|
||||
&self,
|
||||
#[description = "Text to return"] text: String,
|
||||
) -> Result<String, std::io::Error> {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
let definition = Tools.echo_definition();
|
||||
assert_eq!(definition().0.name, "echo");
|
||||
```
|
||||
|
||||
The generated API uses the canonical crate name `agen`. Renaming the `agen` dependency in `Cargo.toml` is not currently supported by these macros.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Default | Adds |
|
||||
|---|---:|---|
|
||||
| `codex` | No | Experimental Codex/ChatGPT auth-file loading and token refresh support |
|
||||
|
||||
The base crate includes provider-neutral transport and Anthropic, OpenAI-compatible, Gemini, and Ollama wire-format schemes. See [`llm_client`](https://docs.rs/agen/latest/agen/llm_client/) for the client boundary.
|
||||
|
||||
## Architecture and API scope
|
||||
|
||||
The current public modules cover the engine, typed history, client transport/schemes, timeline events, tools, interceptors, pruning, token estimation, and usage records. Their relationships are described in [Architecture](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/architecture.md); behavioral requirements are summarized in [Requirements](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/requirements.md).
|
||||
|
||||
Low-level modules remain public in the 0.2 series because downstream Yoi components implement custom clients, event handlers, pruning policies, and tool registries against them. This surface is versioned as pre-1.0 API rather than declared stable.
|
||||
|
||||
## Packaging and security
|
||||
|
||||
The published package contains source, public documentation, curated examples, and deterministic tests/fixtures. Credentialed fixture-recording utilities are intentionally excluded. Examples that contact a provider read credentials from environment variables and never embed production credentials.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
|
||||
@@ -0,0 +1,62 @@
|
||||
# agen architecture
|
||||
|
||||
`agen` separates orchestration, event projection, and provider transport so applications can replace an LLM client without changing the turn loop or tool model.
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Engine │
|
||||
│ turn loop · interceptors · tool execution │
|
||||
│ typed state: Mutable → Locked → Mutable │
|
||||
└─────────────────────┬──────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼──────────────────────┐
|
||||
│ Timeline │
|
||||
│ event dispatch · block collectors │
|
||||
└─────────────────────┬──────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼──────────────────────┐
|
||||
│ LlmClient │
|
||||
│ transport · provider wire-format schemes │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Main modules
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `engine` | Turn execution, pause/resume, retries, tool integration, and callbacks |
|
||||
| `state` | Sealed `Mutable` and `Locked` type-state markers |
|
||||
| `interceptor` | Application-owned control decisions at orchestration boundaries |
|
||||
| `tool` / `tool_server` | Tool metadata, registration, execution, and bounded output |
|
||||
| `timeline` | Streaming event dispatch, handlers, and block assembly |
|
||||
| `llm_client` | Provider-neutral request, response, auth, transport, and scheme contracts |
|
||||
| `providers` | Optional higher-level provider adapters such as the `codex` feature |
|
||||
| `prune` / `token_counter` | Cache-aware history reduction and token estimation |
|
||||
| `usage_record` | Request and token usage accounting |
|
||||
|
||||
## Request flow
|
||||
|
||||
```text
|
||||
Engine history
|
||||
→ provider-neutral Request
|
||||
→ Scheme::build_request
|
||||
→ Provider transport
|
||||
```
|
||||
|
||||
## Response flow
|
||||
|
||||
```text
|
||||
streaming response bytes
|
||||
→ Scheme event parsing
|
||||
→ unified Event values
|
||||
→ Timeline handlers and collectors
|
||||
→ Engine history/tool decisions
|
||||
```
|
||||
|
||||
## Type state and cache protection
|
||||
|
||||
`Engine<C, Mutable>` permits configuration and history editing. `Engine::run` or `Engine::lock` commits the current prefix and produces `Engine<C, Locked>`. The locked engine may append turns without mutating the committed prefix. `Engine::unlock` explicitly returns to mutable state when an application accepts losing that cache guarantee.
|
||||
|
||||
## Public surface
|
||||
|
||||
The 0.2 series exposes the low-level client, timeline, tool, pruning, and usage modules because custom clients and orchestration hosts build directly on them. These APIs are intentionally provider-neutral but remain pre-1.0 and may change in later minor releases.
|
||||
@@ -0,0 +1,39 @@
|
||||
# agen requirements
|
||||
|
||||
## R1: Turn execution and continuation
|
||||
|
||||
- `Engine::run` starts a turn and loops through provider output and tool calls.
|
||||
- An `Interceptor` may continue, cancel, or pause work at defined orchestration boundaries.
|
||||
- `Engine::resume` continues paused generation without fabricating another user message.
|
||||
- Cancellation and provider errors are represented as typed `EngineError` values.
|
||||
|
||||
## R2: Explicit cache-preserving state
|
||||
|
||||
- `Engine<C, Mutable>` permits configuration and history edits.
|
||||
- `Engine::run` or `Engine::lock` transitions to `Engine<C, Locked>` and records the committed prefix.
|
||||
- A locked engine appends turns but cannot mutate that prefix through mutable-only APIs.
|
||||
- `Engine::unlock` explicitly abandons the lock before configuration or history changes.
|
||||
|
||||
## R3: Tool declarations and execution
|
||||
|
||||
- `#[tool_registry]` generates a schema and `Tool` implementation for methods marked `#[tool]`.
|
||||
- `#[description = "..."]` supplies argument descriptions in generated JSON Schema.
|
||||
- Generated code resolves its runtime and helper dependencies through `::agen`.
|
||||
- Invalid and duplicate marker attributes produce compile errors rather than panics.
|
||||
- Tools execute through `ToolServer` with typed context, errors, and output limits.
|
||||
|
||||
## R4: Provider-neutral orchestration
|
||||
|
||||
- `LlmClient` is the boundary between the engine and provider-specific transport.
|
||||
- Request/response schemes translate provider wire formats into shared request and event types.
|
||||
- Interceptors, tool execution, timeline collection, and pruning stay above the provider transport.
|
||||
- Provider-specific capabilities are optional features when they require additional policy or dependencies.
|
||||
|
||||
## R5: Publication quality
|
||||
|
||||
- crates.io metadata includes license, repository, documentation, README, categories, keywords, and MSRV.
|
||||
- The default feature set and each optional feature compile and test independently.
|
||||
- Macro expansion compiles in a downstream-style integration test without direct helper dependencies.
|
||||
- rustdoc builds without dependency documentation.
|
||||
- Package contents are explicitly bounded and exclude credentialed fixture-recording utilities.
|
||||
- `cargo package` and `cargo publish --dry-run` are run for `agen-macros` before `agen` because the main package depends on its companion package.
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Engine cancellation demo
|
||||
//!
|
||||
//! Example of cancelling from another thread during streaming
|
||||
|
||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use agen::{Engine, EngineResult};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Load .env file
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let api_key =
|
||||
std::env::var("ANTHROPIC_API_KEY").expect("ANTHROPIC_API_KEY environment variable not set");
|
||||
|
||||
let scheme = AnthropicScheme::new();
|
||||
let model = "claude-sonnet-4-20250514".to_string();
|
||||
let cap = scheme.default_capability();
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||
let engine = Engine::new(client);
|
||||
|
||||
println!("🚀 Starting Engine...");
|
||||
println!("💡 Will cancel after 2 seconds\n");
|
||||
|
||||
// Get cancel sender before run (Mutable state)
|
||||
let cancel_tx = engine.cancel_sender();
|
||||
|
||||
// Task: Cancel after 2 seconds
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
println!("\n🛑 Cancelling engine...");
|
||||
let _ = cancel_tx.send(()).await;
|
||||
});
|
||||
|
||||
println!("📡 Sending request to LLM...");
|
||||
|
||||
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
|
||||
Ok(out) => match out.result {
|
||||
EngineResult::Finished => println!("✅ Task completed normally"),
|
||||
EngineResult::Paused => println!("⏸️ Task paused"),
|
||||
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
|
||||
EngineResult::Yielded => println!("↩️ Task yielded"),
|
||||
},
|
||||
Err(e) => {
|
||||
println!("❌ Task error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n✨ Demo complete!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! Interactive CLI client using Engine
|
||||
//!
|
||||
//! A CLI application for interacting with multiple LLM providers (Anthropic, Gemini, OpenAI, Ollama).
|
||||
//! Demonstrates tool registration and execution, and streaming response display.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Set API keys in .env file
|
||||
//! echo "ANTHROPIC_API_KEY=your-api-key" > .env
|
||||
//! echo "GEMINI_API_KEY=your-api-key" >> .env
|
||||
//! echo "OPENAI_API_KEY=your-api-key" >> .env
|
||||
//!
|
||||
//! # Anthropic (default)
|
||||
//! cargo run --example engine_cli
|
||||
//!
|
||||
//! # Gemini
|
||||
//! cargo run --example engine_cli -- --provider gemini
|
||||
//!
|
||||
//! # OpenAI
|
||||
//! cargo run --example engine_cli -- --provider openai --model gpt-4o
|
||||
//!
|
||||
//! # Ollama (local)
|
||||
//! cargo run --example engine_cli -- --provider ollama --model llama3.2
|
||||
//!
|
||||
//! # With options
|
||||
//! cargo run --example engine_cli -- --provider anthropic --model claude-3-haiku-20240307 --system "You are a helpful assistant."
|
||||
//!
|
||||
//! # Show help
|
||||
//! cargo run --example engine_cli -- --help
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use agen::{
|
||||
Engine,
|
||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||
llm_client::{
|
||||
LlmClient,
|
||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||
scheme::{
|
||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||
},
|
||||
transport::{HttpTransport, ResolvedAuth},
|
||||
},
|
||||
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
||||
tool_registry,
|
||||
};
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
// =============================================================================
|
||||
// Provider Definition
|
||||
// =============================================================================
|
||||
|
||||
/// Available LLM providers
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
|
||||
enum Provider {
|
||||
/// Anthropic Claude
|
||||
#[default]
|
||||
Anthropic,
|
||||
/// Google Gemini
|
||||
Gemini,
|
||||
/// OpenAI GPT
|
||||
Openai,
|
||||
/// Ollama (local)
|
||||
Ollama,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// Default model for the provider
|
||||
fn default_model(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Anthropic => "claude-sonnet-4-20250514",
|
||||
Provider::Gemini => "gemini-2.0-flash",
|
||||
Provider::Openai => "gpt-4o",
|
||||
Provider::Ollama => "llama3.2",
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name for the provider
|
||||
fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Anthropic => "Anthropic Claude",
|
||||
Provider::Gemini => "Google Gemini",
|
||||
Provider::Openai => "OpenAI GPT",
|
||||
Provider::Ollama => "Ollama (Local)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment variable name for API key
|
||||
fn env_var_name(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Provider::Anthropic => Some("ANTHROPIC_API_KEY"),
|
||||
Provider::Gemini => Some("GEMINI_API_KEY"),
|
||||
Provider::Openai => Some("OPENAI_API_KEY"),
|
||||
Provider::Ollama => None, // Ollama is local, no key needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CLI Argument Definition
|
||||
// =============================================================================
|
||||
|
||||
/// Interactive CLI client supporting multiple LLM providers
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "engine-cli")]
|
||||
#[command(about = "Interactive CLI client for multiple LLM providers using Engine")]
|
||||
#[command(version)]
|
||||
struct Args {
|
||||
/// Provider to use
|
||||
#[arg(long, value_enum, default_value_t = Provider::Anthropic)]
|
||||
provider: Provider,
|
||||
|
||||
/// Model name to use (defaults to provider's default if not specified)
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
|
||||
/// System prompt
|
||||
#[arg(short, long)]
|
||||
system: Option<String>,
|
||||
|
||||
/// Disable tools
|
||||
#[arg(long, default_value = "false")]
|
||||
no_tools: bool,
|
||||
|
||||
/// Initial message (if specified, sends it and exits)
|
||||
#[arg(short = 'p', long)]
|
||||
prompt: Option<String>,
|
||||
|
||||
/// API key (takes precedence over environment variable)
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Definition
|
||||
// =============================================================================
|
||||
|
||||
/// Application context
|
||||
#[derive(Clone)]
|
||||
struct AppContext;
|
||||
|
||||
#[tool_registry]
|
||||
impl AppContext {
|
||||
/// Get the current date and time
|
||||
///
|
||||
/// Returns the system's current date and time.
|
||||
#[tool]
|
||||
fn get_current_time(&self) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
// Simple conversion from Unix timestamp
|
||||
format!("Current Unix timestamp: {}", now)
|
||||
}
|
||||
|
||||
/// Perform a simple calculation
|
||||
///
|
||||
/// Executes arithmetic operations on two numbers.
|
||||
#[tool]
|
||||
fn calculate(&self, a: f64, b: f64, operation: String) -> Result<String, String> {
|
||||
let result = match operation.as_str() {
|
||||
"add" | "+" => a + b,
|
||||
"subtract" | "-" => a - b,
|
||||
"multiply" | "*" => a * b,
|
||||
"divide" | "/" => {
|
||||
if b == 0.0 {
|
||||
return Err("Cannot divide by zero".to_string());
|
||||
}
|
||||
a / b
|
||||
}
|
||||
_ => return Err(format!("Unknown operation: {}", operation)),
|
||||
};
|
||||
Ok(format!("{} {} {} = {}", a, operation, b, result))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Streaming Display Handlers
|
||||
// =============================================================================
|
||||
|
||||
/// Handler that outputs text in real-time
|
||||
struct StreamingPrinter {
|
||||
is_first_delta: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl StreamingPrinter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
is_first_delta: Arc::new(Mutex::new(true)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for StreamingPrinter {
|
||||
type Scope = ();
|
||||
|
||||
fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) {
|
||||
match event {
|
||||
TextBlockEvent::Start(_) => {
|
||||
let mut first = self.is_first_delta.lock().unwrap();
|
||||
if *first {
|
||||
print!("\n🤖 ");
|
||||
*first = false;
|
||||
}
|
||||
}
|
||||
TextBlockEvent::Delta(text) => {
|
||||
print!("{}", text);
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
TextBlockEvent::Stop(_) => {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler that displays tool calls
|
||||
struct ToolCallPrinter {
|
||||
call_names: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl ToolCallPrinter {
|
||||
fn new(call_names: Arc<Mutex<HashMap<String, String>>>) -> Self {
|
||||
Self { call_names }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ToolCallPrinterScope {
|
||||
input_json: String,
|
||||
}
|
||||
|
||||
impl Handler<ToolUseBlockKind> for ToolCallPrinter {
|
||||
type Scope = ToolCallPrinterScope;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
|
||||
match event {
|
||||
ToolUseBlockEvent::Start(start) => {
|
||||
scope.input_json.clear();
|
||||
self.call_names
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(start.id.clone(), start.name.clone());
|
||||
println!("\n🔧 Calling tool: {}", start.name);
|
||||
}
|
||||
ToolUseBlockEvent::InputJsonDelta(json) => {
|
||||
scope.input_json.push_str(json);
|
||||
}
|
||||
ToolUseBlockEvent::Stop(_) => {
|
||||
if scope.input_json.is_empty() {
|
||||
println!(" Args: {{}}");
|
||||
} else {
|
||||
println!(" Args: {}", scope.input_json);
|
||||
}
|
||||
scope.input_json.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Policy that displays tool execution results.
|
||||
struct ToolResultPrinterPolicy {
|
||||
call_names: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl ToolResultPrinterPolicy {
|
||||
fn new(call_names: Arc<Mutex<HashMap<String, String>>>) -> Self {
|
||||
Self { call_names }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ToolResultPrinterPolicy {
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
let name = self
|
||||
.call_names
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&info.result.tool_use_id)
|
||||
.unwrap_or_else(|| info.result.tool_use_id.clone());
|
||||
|
||||
if info.result.is_error {
|
||||
println!(" Result ({}): ❌ {}", name, info.result.summary);
|
||||
} else {
|
||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||
}
|
||||
|
||||
PostToolAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Client Creation
|
||||
// =============================================================================
|
||||
|
||||
/// Get API key based on provider
|
||||
fn get_api_key(args: &Args) -> Result<String, String> {
|
||||
// CLI argument API key takes precedence
|
||||
if let Some(ref key) = args.api_key {
|
||||
return Ok(key.clone());
|
||||
}
|
||||
|
||||
// Check environment variable based on provider
|
||||
if let Some(env_var) = args.provider.env_var_name() {
|
||||
std::env::var(env_var).map_err(|_| {
|
||||
format!(
|
||||
"API key required. Set {} environment variable or use --api-key",
|
||||
env_var
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// Ollama etc. don't need a key
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Create client based on provider
|
||||
fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transport<S: Scheme>(scheme: S, model: String, auth: ResolvedAuth) -> Box<dyn LlmClient> {
|
||||
let cap = scheme.default_capability();
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
Box::new(HttpTransport::new(scheme, model, base_url, auth, cap))
|
||||
}
|
||||
|
||||
fn create_client(args: &Args) -> Result<Box<dyn LlmClient>, String> {
|
||||
let model = args
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| args.provider.default_model().to_string());
|
||||
|
||||
let api_key = get_api_key(args)?;
|
||||
|
||||
match args.provider {
|
||||
Provider::Anthropic => Ok(build_transport(
|
||||
AnthropicScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Gemini => Ok(build_transport(
|
||||
GeminiScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Openai => Ok(build_transport(
|
||||
OpenAIScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Ollama => {
|
||||
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
|
||||
let scheme = AnthropicScheme::new();
|
||||
let cap = default_capability();
|
||||
Ok(Box::new(HttpTransport::new(
|
||||
scheme,
|
||||
model,
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
cap,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main
|
||||
// =============================================================================
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Load .env file
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
// Initialize logging
|
||||
// Use RUST_LOG=debug cargo run --example engine_cli ... for detailed logs
|
||||
// Default is warn level, can be overridden with RUST_LOG environment variable
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(true)
|
||||
.init();
|
||||
|
||||
// Parse CLI arguments
|
||||
let args = Args::parse();
|
||||
|
||||
info!(
|
||||
provider = ?args.provider,
|
||||
model = ?args.model,
|
||||
"Starting engine CLI"
|
||||
);
|
||||
|
||||
// Interactive mode or one-shot mode
|
||||
let is_interactive = args.prompt.is_none();
|
||||
|
||||
// Model name (for display)
|
||||
let model_name = args
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| args.provider.default_model().to_string());
|
||||
|
||||
if is_interactive {
|
||||
let title = format!("Engine CLI - {}", args.provider.display_name());
|
||||
let border_len = title.len() + 6;
|
||||
println!("╔{}╗", "═".repeat(border_len));
|
||||
println!("║ {} ║", title);
|
||||
println!("╚{}╝", "═".repeat(border_len));
|
||||
println!();
|
||||
println!("Provider: {}", args.provider.display_name());
|
||||
println!("Model: {}", model_name);
|
||||
if let Some(ref system) = args.system {
|
||||
println!("System: {}", system);
|
||||
}
|
||||
if args.no_tools {
|
||||
println!("Tools: disabled");
|
||||
} else {
|
||||
println!("Tools:");
|
||||
println!(" • get_current_time - Get the current timestamp");
|
||||
println!(" • calculate - Perform arithmetic (add, subtract, multiply, divide)");
|
||||
}
|
||||
println!();
|
||||
println!("Type 'quit' or 'exit' to end the session.");
|
||||
println!("─────────────────────────────────────────────────");
|
||||
}
|
||||
|
||||
// Create client
|
||||
let client = match create_client(&args) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("❌ Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Create Engine
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Set system prompt
|
||||
if let Some(ref system_prompt) = args.system {
|
||||
engine.set_system_prompt(system_prompt);
|
||||
}
|
||||
|
||||
// Register tools (unless --no-tools)
|
||||
if !args.no_tools {
|
||||
let app = AppContext;
|
||||
engine.register_tool(app.get_current_time_definition());
|
||||
engine.register_tool(app.calculate_definition());
|
||||
}
|
||||
|
||||
// Register streaming display handlers
|
||||
engine
|
||||
.timeline_mut()
|
||||
.on_text_block(StreamingPrinter::new())
|
||||
.on_tool_use_block(ToolCallPrinter::new(tool_call_names.clone()));
|
||||
|
||||
engine.set_interceptor(ToolResultPrinterPolicy::new(tool_call_names));
|
||||
|
||||
// One-shot mode
|
||||
if let Some(prompt) = args.prompt {
|
||||
match engine.run(&prompt).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Interactive loop — first input transitions Mutable → Locked
|
||||
print!("\n👤 You: ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut first_input = String::new();
|
||||
io::stdin().read_line(&mut first_input)?;
|
||||
let first_input = first_input.trim();
|
||||
|
||||
if first_input == "quit" || first_input == "exit" || first_input.is_empty() {
|
||||
println!("\n👋 Goodbye!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut locked = match engine.run(first_input).await {
|
||||
Ok(out) => out.engine,
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
print!("\n👤 You: ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let input = input.trim();
|
||||
|
||||
if input.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if input == "quit" || input == "exit" {
|
||||
println!("\n👋 Goodbye!");
|
||||
break;
|
||||
}
|
||||
|
||||
match locked.run(input).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Test fixture recording tool
|
||||
//!
|
||||
//! Records API responses for defined scenarios.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Show available scenarios
|
||||
//! cargo run --example record_test_fixtures
|
||||
//!
|
||||
//! # Record specific scenario
|
||||
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- simple_text
|
||||
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- tool_call
|
||||
//!
|
||||
//! # Record all scenarios
|
||||
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- --all
|
||||
//! ```
|
||||
|
||||
mod recorder;
|
||||
mod scenarios;
|
||||
|
||||
use agen::llm_client::scheme::{
|
||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||
};
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
||||
let cap = scheme.default_capability();
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
HttpTransport::new(scheme, model.to_string(), base_url, auth, cap)
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Scenario name
|
||||
#[arg(short, long)]
|
||||
scenario: Option<String>,
|
||||
|
||||
/// Run all scenarios
|
||||
#[arg(long, default_value_t = false)]
|
||||
all: bool,
|
||||
|
||||
/// Client to use
|
||||
#[arg(short, long, value_enum, default_value_t = ClientType::Anthropic)]
|
||||
client: ClientType,
|
||||
|
||||
/// Model to use (optional, defaults per client)
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
|
||||
enum ClientType {
|
||||
Anthropic,
|
||||
Gemini,
|
||||
Openai,
|
||||
Ollama,
|
||||
}
|
||||
|
||||
async fn run_scenario_with_anthropic(
|
||||
scenario: &scenarios::TestScenario,
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> 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 = model.as_deref().unwrap_or("claude-sonnet-4-20250514");
|
||||
let client = make_transport(AnthropicScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
scenario.request.clone(),
|
||||
scenario.name,
|
||||
scenario.output_name,
|
||||
subdir,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_scenario_with_openai(
|
||||
scenario: &scenarios::TestScenario,
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_key =
|
||||
std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY environment variable must be set");
|
||||
let model = model.as_deref().unwrap_or("gpt-4o");
|
||||
let client = make_transport(OpenAIScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
scenario.request.clone(),
|
||||
scenario.name,
|
||||
scenario.output_name,
|
||||
subdir,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_scenario_with_ollama(
|
||||
scenario: &scenarios::TestScenario,
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
|
||||
let model = model.as_deref().unwrap_or("llama3");
|
||||
let client = HttpTransport::new(
|
||||
AnthropicScheme::new(),
|
||||
model.to_string(),
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
AnthropicScheme::new().default_capability(),
|
||||
);
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
scenario.request.clone(),
|
||||
scenario.name,
|
||||
scenario.output_name,
|
||||
subdir,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_scenario_with_gemini(
|
||||
scenario: &scenarios::TestScenario,
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_key =
|
||||
std::env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY environment variable must be set");
|
||||
let model = model.as_deref().unwrap_or("gemini-2.0-flash");
|
||||
let client = make_transport(GeminiScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
scenario.request.clone(),
|
||||
scenario.name,
|
||||
scenario.output_name,
|
||||
subdir,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenv::dotenv().ok();
|
||||
let args = Args::parse();
|
||||
|
||||
if !args.all && args.scenario.is_none() {
|
||||
use clap::CommandFactory;
|
||||
let mut cmd = Args::command();
|
||||
cmd.error(
|
||||
clap::error::ErrorKind::MissingRequiredArgument,
|
||||
"Either --all or --scenario <SCENARIO> must be provided",
|
||||
)
|
||||
.exit();
|
||||
}
|
||||
|
||||
let all_scenarios = scenarios::scenarios();
|
||||
|
||||
// Determine scenarios to run
|
||||
let scenarios_to_run: Vec<_> = if args.all {
|
||||
all_scenarios
|
||||
} else {
|
||||
let scenario_name = args.scenario.as_ref().unwrap();
|
||||
let found: Vec<_> = all_scenarios
|
||||
.into_iter()
|
||||
.filter(|s| s.output_name == scenario_name)
|
||||
.collect();
|
||||
|
||||
if found.is_empty() {
|
||||
eprintln!("Error: Unknown scenario '{}'", scenario_name);
|
||||
// Verify correct name by listing
|
||||
println!("Available scenarios:");
|
||||
for s in scenarios::scenarios() {
|
||||
println!(" {}", s.output_name);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
println!("=== Test Fixture Generator ===");
|
||||
println!("Client: {:?}", args.client);
|
||||
if let Some(ref m) = args.model {
|
||||
println!("Model: {}", m);
|
||||
}
|
||||
println!("Scenarios: {}\n", scenarios_to_run.len());
|
||||
|
||||
let subdir = match args.client {
|
||||
ClientType::Anthropic => "anthropic",
|
||||
ClientType::Gemini => "gemini",
|
||||
ClientType::Openai => "openai",
|
||||
ClientType::Ollama => "ollama",
|
||||
};
|
||||
|
||||
// Scenario filtering is already done in main.rs logic
|
||||
// Here we just execute in a simple loop
|
||||
for scenario in scenarios_to_run {
|
||||
match args.client {
|
||||
ClientType::Anthropic => {
|
||||
run_scenario_with_anthropic(&scenario, subdir, args.model.clone()).await?
|
||||
}
|
||||
ClientType::Gemini => {
|
||||
run_scenario_with_gemini(&scenario, subdir, args.model.clone()).await?
|
||||
}
|
||||
ClientType::Openai => {
|
||||
run_scenario_with_openai(&scenario, subdir, args.model.clone()).await?
|
||||
}
|
||||
ClientType::Ollama => {
|
||||
run_scenario_with_ollama(&scenario, subdir, args.model.clone()).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n✅ Done!");
|
||||
println!("Run tests with: cargo test -p engine");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Test fixture recording mechanism
|
||||
//!
|
||||
//! Saves events to files in JSONL format
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use agen::llm_client::{LlmClient, Request};
|
||||
use futures::StreamExt;
|
||||
|
||||
/// Recorded event
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RecordedEvent {
|
||||
pub elapsed_ms: u64,
|
||||
pub event_type: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Session metadata
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SessionMetadata {
|
||||
pub timestamp: u64,
|
||||
pub model: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Save event sequence to file
|
||||
pub fn save_fixture(
|
||||
path: impl AsRef<Path>,
|
||||
metadata: &SessionMetadata,
|
||||
events: &[RecordedEvent],
|
||||
) -> std::io::Result<()> {
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
writeln!(writer, "{}", serde_json::to_string(metadata)?)?;
|
||||
for event in events {
|
||||
writeln!(writer, "{}", serde_json::to_string(event)?)?;
|
||||
}
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send request and record events
|
||||
pub async fn record_request<C: LlmClient>(
|
||||
client: &C,
|
||||
request: Request,
|
||||
description: &str,
|
||||
output_name: &str,
|
||||
subdir: &str, // e.g. "anthropic", "openai"
|
||||
model: &str,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
println!("\n📝 Recording: {}", description);
|
||||
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save
|
||||
let fixtures_dir = Path::new("engine/tests/fixtures").join(subdir);
|
||||
fs::create_dir_all(&fixtures_dir)?;
|
||||
|
||||
let filepath = fixtures_dir.join(format!("{}.jsonl", output_name));
|
||||
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
let metadata = SessionMetadata {
|
||||
timestamp,
|
||||
model: model.to_string(),
|
||||
description: description.to_string(),
|
||||
};
|
||||
|
||||
save_fixture(&filepath, &metadata, &events)?;
|
||||
|
||||
let event_count = events.len();
|
||||
println!(" 💾 Saved: {}", filepath.display());
|
||||
println!(" 📊 {} events recorded", event_count);
|
||||
|
||||
Ok(event_count)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Test fixture request definitions
|
||||
//!
|
||||
//! Defines requests and output file names for each scenario
|
||||
|
||||
use agen::llm_client::{Request, ToolDefinition};
|
||||
|
||||
/// Test scenario
|
||||
pub struct TestScenario {
|
||||
/// Scenario name (description)
|
||||
pub name: &'static str,
|
||||
/// Output file name (without extension)
|
||||
pub output_name: &'static str,
|
||||
/// Request
|
||||
pub request: Request,
|
||||
}
|
||||
|
||||
/// Get all test scenarios
|
||||
pub fn scenarios() -> Vec<TestScenario> {
|
||||
vec![
|
||||
simple_text_scenario(),
|
||||
tool_call_scenario(),
|
||||
long_text_scenario(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Simple text response
|
||||
fn simple_text_scenario() -> TestScenario {
|
||||
TestScenario {
|
||||
name: "Simple text response",
|
||||
output_name: "simple_text",
|
||||
request: Request::new()
|
||||
.system("You are a helpful assistant. Be very concise.")
|
||||
.user("Say hello in one word.")
|
||||
.max_tokens(50),
|
||||
}
|
||||
}
|
||||
|
||||
/// Response with tool call
|
||||
fn tool_call_scenario() -> TestScenario {
|
||||
let get_weather_tool = ToolDefinition::new("get_weather")
|
||||
.description("Get the current weather for a city")
|
||||
.input_schema(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "The city name"
|
||||
}
|
||||
},
|
||||
"required": ["city"]
|
||||
}));
|
||||
|
||||
TestScenario {
|
||||
name: "Tool call response",
|
||||
output_name: "tool_call",
|
||||
request: Request::new()
|
||||
.system("You are a helpful assistant. Use tools when appropriate.")
|
||||
.user("What's the weather in Tokyo? Use the get_weather tool.")
|
||||
.tool(get_weather_tool)
|
||||
.max_tokens(200),
|
||||
}
|
||||
}
|
||||
|
||||
/// Long text generation scenario
|
||||
fn long_text_scenario() -> TestScenario {
|
||||
TestScenario {
|
||||
name: "Long text response",
|
||||
output_name: "long_text",
|
||||
request: Request::new()
|
||||
.system("You are a creative writer.")
|
||||
.user("Write a short story about a robot discovering a garden. It should be at least 300 words.")
|
||||
.max_tokens(1000),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Closure-based event callback API
|
||||
//!
|
||||
//! Provides a closure-based alternative to implementing `Handler<K>` directly.
|
||||
//! Register callbacks on `Engine` via `on_text_block()`, `on_tool_use_block()`,
|
||||
//! `on_usage()`, etc.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::handler::{
|
||||
Handler, Kind, TextBlockEvent, TextBlockKind, ThinkingBlockEvent, ThinkingBlockKind,
|
||||
ToolUseBlockEvent, ToolUseBlockKind, ToolUseBlockStart,
|
||||
};
|
||||
use crate::tool::ToolCall;
|
||||
|
||||
// =============================================================================
|
||||
// TextBlock Closure Handler
|
||||
// =============================================================================
|
||||
|
||||
/// Callback scope for a text block.
|
||||
///
|
||||
/// Passed to the setup closure registered with `Engine::on_text_block()`.
|
||||
/// Register per-block callbacks via `on_delta()` and `on_stop()`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// engine.on_text_block(|block| {
|
||||
/// block.on_delta(|text| print!("{}", text));
|
||||
/// block.on_stop(|full_text| println!("\n--- {} chars ---", full_text.len()));
|
||||
/// });
|
||||
/// ```
|
||||
pub struct TextBlockScope {
|
||||
pub(crate) on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
pub(crate) on_stop: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl TextBlockScope {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
on_delta: None,
|
||||
on_stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback for each text delta (streaming fragment).
|
||||
pub fn on_delta(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_delta = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Register a callback invoked when the block completes.
|
||||
///
|
||||
/// Receives the full accumulated text of the block.
|
||||
pub fn on_stop(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_stop = Some(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-block state created by Timeline's scope lifecycle.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TextBlockClosureState {
|
||||
on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
on_stop: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
/// Closure-based `Handler<TextBlockKind>` adapter.
|
||||
pub(crate) struct ClosureTextBlockHandler {
|
||||
pub(crate) setup: Box<dyn FnMut(&mut TextBlockScope) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for ClosureTextBlockHandler {
|
||||
type Scope = TextBlockClosureState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &TextBlockEvent) {
|
||||
match event {
|
||||
TextBlockEvent::Start(_) => {
|
||||
scope.buffer.clear();
|
||||
let mut builder = TextBlockScope::new();
|
||||
(self.setup)(&mut builder);
|
||||
scope.on_delta = builder.on_delta;
|
||||
scope.on_stop = builder.on_stop;
|
||||
}
|
||||
TextBlockEvent::Delta(text) => {
|
||||
scope.buffer.push_str(text);
|
||||
if let Some(f) = &mut scope.on_delta {
|
||||
f(text);
|
||||
}
|
||||
}
|
||||
TextBlockEvent::Stop(_) => {
|
||||
if let Some(f) = &mut scope.on_stop {
|
||||
f(&scope.buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ThinkingBlock Closure Handler
|
||||
// =============================================================================
|
||||
|
||||
/// Callback scope for a thinking block.
|
||||
///
|
||||
/// Mirrors `TextBlockScope`. Some providers (or some configurations)
|
||||
/// emit thinking metadata without plaintext deltas — in that case the
|
||||
/// block fires `Start` and `Stop` with no `Delta` in between, which is
|
||||
/// expected and not an error.
|
||||
pub struct ThinkingBlockScope {
|
||||
pub(crate) on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
pub(crate) on_stop: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ThinkingBlockScope {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
on_delta: None,
|
||||
on_stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback for each thinking text delta (streaming fragment).
|
||||
pub fn on_delta(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_delta = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Register a callback invoked when the block completes.
|
||||
///
|
||||
/// Receives the full accumulated thinking text. May be empty when
|
||||
/// the provider didn't emit any plaintext deltas.
|
||||
pub fn on_stop(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_stop = Some(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ThinkingBlockClosureState {
|
||||
on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
on_stop: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
pub(crate) struct ClosureThinkingBlockHandler {
|
||||
pub(crate) setup: Box<dyn FnMut(&mut ThinkingBlockScope) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Handler<ThinkingBlockKind> for ClosureThinkingBlockHandler {
|
||||
type Scope = ThinkingBlockClosureState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ThinkingBlockEvent) {
|
||||
match event {
|
||||
ThinkingBlockEvent::Start(_) => {
|
||||
scope.buffer.clear();
|
||||
let mut builder = ThinkingBlockScope::new();
|
||||
(self.setup)(&mut builder);
|
||||
scope.on_delta = builder.on_delta;
|
||||
scope.on_stop = builder.on_stop;
|
||||
}
|
||||
ThinkingBlockEvent::Delta(text) => {
|
||||
scope.buffer.push_str(text);
|
||||
if let Some(f) = &mut scope.on_delta {
|
||||
f(text);
|
||||
}
|
||||
}
|
||||
ThinkingBlockEvent::Stop(_) => {
|
||||
if let Some(f) = &mut scope.on_stop {
|
||||
f(&scope.buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolUseBlock Closure Handler
|
||||
// =============================================================================
|
||||
|
||||
/// Callback scope for a tool use block.
|
||||
///
|
||||
/// Passed to the setup closure registered with `Engine::on_tool_use_block()`.
|
||||
/// The setup closure also receives `&ToolUseBlockStart` with `id` and `name`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// engine.on_tool_use_block(|start, block| {
|
||||
/// println!("Tool: {} ({})", start.name, start.id);
|
||||
/// block.on_delta(|json| { /* streaming JSON fragment */ });
|
||||
/// block.on_stop(|call| println!("Done: {}", call.name));
|
||||
/// });
|
||||
/// ```
|
||||
pub struct ToolUseBlockScope {
|
||||
pub(crate) on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
pub(crate) on_stop: Option<Box<dyn FnMut(&ToolCall) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ToolUseBlockScope {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
on_delta: None,
|
||||
on_stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback for each JSON input delta (streaming fragment).
|
||||
pub fn on_delta(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_delta = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Register a callback invoked when the block completes.
|
||||
///
|
||||
/// Receives the fully assembled `ToolCall` with parsed JSON input.
|
||||
pub fn on_stop(&mut self, f: impl FnMut(&ToolCall) + Send + Sync + 'static) {
|
||||
self.on_stop = Some(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-block state for tool use closure handler.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ToolUseBlockClosureState {
|
||||
on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
on_stop: Option<Box<dyn FnMut(&ToolCall) + Send + Sync>>,
|
||||
id: String,
|
||||
name: String,
|
||||
input_json: String,
|
||||
}
|
||||
|
||||
/// Closure-based `Handler<ToolUseBlockKind>` adapter.
|
||||
pub(crate) struct ClosureToolUseBlockHandler {
|
||||
pub(crate) setup: Box<dyn FnMut(&ToolUseBlockStart, &mut ToolUseBlockScope) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Handler<ToolUseBlockKind> for ClosureToolUseBlockHandler {
|
||||
type Scope = ToolUseBlockClosureState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
|
||||
match event {
|
||||
ToolUseBlockEvent::Start(start) => {
|
||||
scope.id = start.id.clone();
|
||||
scope.name = start.name.clone();
|
||||
scope.input_json.clear();
|
||||
let mut builder = ToolUseBlockScope::new();
|
||||
(self.setup)(start, &mut builder);
|
||||
scope.on_delta = builder.on_delta;
|
||||
scope.on_stop = builder.on_stop;
|
||||
}
|
||||
ToolUseBlockEvent::InputJsonDelta(json) => {
|
||||
scope.input_json.push_str(json);
|
||||
if let Some(f) = &mut scope.on_delta {
|
||||
f(json);
|
||||
}
|
||||
}
|
||||
ToolUseBlockEvent::Stop(_) => {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(&scope.input_json).unwrap_or_default();
|
||||
let tool_call = ToolCall {
|
||||
id: std::mem::take(&mut scope.id),
|
||||
name: std::mem::take(&mut scope.name),
|
||||
input,
|
||||
};
|
||||
if let Some(f) = &mut scope.on_stop {
|
||||
f(&tool_call);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Generic Meta Event Closure Handler
|
||||
// =============================================================================
|
||||
|
||||
/// Closure-based `Handler<K>` adapter for meta events (Usage, Status, Error).
|
||||
pub(crate) struct ClosureMetaHandler<F, K>
|
||||
where
|
||||
K: Kind,
|
||||
{
|
||||
pub(crate) callback: F,
|
||||
pub(crate) _kind: PhantomData<K>,
|
||||
}
|
||||
|
||||
impl<F, K> Handler<K> for ClosureMetaHandler<F, K>
|
||||
where
|
||||
F: FnMut(&K::Event) + Send + Sync,
|
||||
K: Kind,
|
||||
{
|
||||
type Scope = ();
|
||||
|
||||
fn on_event(&mut self, _scope: &mut (), event: &K::Event) {
|
||||
(self.callback)(event);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
//! Public event types for Engine layer
|
||||
//!
|
||||
//! Re-exports from the canonical event definitions in llm_client.
|
||||
|
||||
pub use crate::llm_client::event::*;
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Handler/Kind Types
|
||||
//!
|
||||
//! Traits for processing events in the Timeline layer.
|
||||
//! By implementing custom handlers and registering them with Timeline,
|
||||
//! you can receive stream events.
|
||||
|
||||
use crate::timeline::event::*;
|
||||
|
||||
// =============================================================================
|
||||
// Kind Trait
|
||||
// =============================================================================
|
||||
|
||||
/// Marker trait defining event types
|
||||
///
|
||||
/// Each Kind specifies its corresponding event type.
|
||||
/// Handlers are implemented for this Kind, and multiple Handlers
|
||||
/// with different Scope types can be registered for the same Kind.
|
||||
pub trait Kind {
|
||||
/// Event type corresponding to this Kind
|
||||
type Event;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Handler Trait
|
||||
// =============================================================================
|
||||
|
||||
/// Handler trait for processing events
|
||||
///
|
||||
/// Defines event processing for a specific `Kind`.
|
||||
/// `Scope` is state held during the block's lifecycle.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use agen::timeline::{Handler, TextBlockEvent, TextBlockKind};
|
||||
///
|
||||
/// struct TextCollector {
|
||||
/// texts: Vec<String>,
|
||||
/// }
|
||||
///
|
||||
/// impl Handler<TextBlockKind> for TextCollector {
|
||||
/// type Scope = String; // Buffer per block
|
||||
///
|
||||
/// fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
|
||||
/// match event {
|
||||
/// TextBlockEvent::Delta(text) => buffer.push_str(text),
|
||||
/// TextBlockEvent::Stop(_) => {
|
||||
/// self.texts.push(std::mem::take(buffer));
|
||||
/// }
|
||||
/// _ => {}
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait Handler<K: Kind> {
|
||||
/// Handler-specific scope type
|
||||
///
|
||||
/// Generated with `Default::default()` at block start,
|
||||
/// and destroyed at block end.
|
||||
type Scope: Default;
|
||||
|
||||
/// Process the event
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &K::Event);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Meta Kind Definitions
|
||||
// =============================================================================
|
||||
|
||||
/// Usage Kind - for usage events
|
||||
pub struct UsageKind;
|
||||
impl Kind for UsageKind {
|
||||
type Event = UsageEvent;
|
||||
}
|
||||
|
||||
/// Ping Kind - for ping events
|
||||
pub struct PingKind;
|
||||
impl Kind for PingKind {
|
||||
type Event = PingEvent;
|
||||
}
|
||||
|
||||
/// Status Kind - for status events
|
||||
pub struct StatusKind;
|
||||
impl Kind for StatusKind {
|
||||
type Event = StatusEvent;
|
||||
}
|
||||
|
||||
/// Error Kind - for error events
|
||||
pub struct ErrorKind;
|
||||
impl Kind for ErrorKind {
|
||||
type Event = ErrorEvent;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Block Kind Definitions
|
||||
// =============================================================================
|
||||
|
||||
/// TextBlock Kind - for text blocks
|
||||
pub struct TextBlockKind;
|
||||
impl Kind for TextBlockKind {
|
||||
type Event = TextBlockEvent;
|
||||
}
|
||||
|
||||
/// Text block events
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TextBlockEvent {
|
||||
Start(TextBlockStart),
|
||||
Delta(String),
|
||||
Stop(TextBlockStop),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextBlockStart {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextBlockStop {
|
||||
pub index: usize,
|
||||
pub stop_reason: Option<StopReason>,
|
||||
}
|
||||
|
||||
/// ThinkingBlock Kind - for thinking blocks
|
||||
pub struct ThinkingBlockKind;
|
||||
impl Kind for ThinkingBlockKind {
|
||||
type Event = ThinkingBlockEvent;
|
||||
}
|
||||
|
||||
/// Thinking block events
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ThinkingBlockEvent {
|
||||
Start(ThinkingBlockStart),
|
||||
Delta(String),
|
||||
Stop(ThinkingBlockStop),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ThinkingBlockStart {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ThinkingBlockStop {
|
||||
pub index: usize,
|
||||
pub reasoning: Option<ReasoningBlockData>,
|
||||
}
|
||||
|
||||
/// ToolUseBlock Kind - for tool use blocks
|
||||
pub struct ToolUseBlockKind;
|
||||
impl Kind for ToolUseBlockKind {
|
||||
type Event = ToolUseBlockEvent;
|
||||
}
|
||||
|
||||
/// Tool use block events
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ToolUseBlockEvent {
|
||||
Start(ToolUseBlockStart),
|
||||
/// JSON substring of tool arguments
|
||||
InputJsonDelta(String),
|
||||
Stop(ToolUseBlockStop),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolUseBlockStart {
|
||||
pub index: usize,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolUseBlockStop {
|
||||
pub index: usize,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Interceptor - control flow delegation for the Engine execution loop
|
||||
//!
|
||||
//! Defines the [`Interceptor`] trait that callers implement to inject
|
||||
//! orchestration decisions (approval, skip, pause, abort) into the Engine's
|
||||
//! turn loop without the Engine knowing about host-application concepts.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::Item;
|
||||
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
|
||||
|
||||
// =============================================================================
|
||||
// Action Enums
|
||||
// =============================================================================
|
||||
|
||||
/// Action after prompt submission.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PromptAction {
|
||||
/// Proceed normally.
|
||||
Continue,
|
||||
/// Cancel with a reason.
|
||||
Cancel(String),
|
||||
/// Proceed, and append these items to history right after the user
|
||||
/// message. Mirrors [`TurnEndAction::ContinueWithMessages`] for the
|
||||
/// submit edge: lets the upper layer attach resolver-produced
|
||||
/// system messages (e.g. `@<path>` file content) so they sit
|
||||
/// adjacent to the user message that referenced them.
|
||||
ContinueWith(Vec<Item>),
|
||||
}
|
||||
|
||||
/// Action before an LLM request.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PreRequestAction {
|
||||
/// Proceed normally.
|
||||
Continue,
|
||||
/// Proceed after appending these items to durable engine history.
|
||||
///
|
||||
/// This is for upper-layer budget/status nudges that the model may react
|
||||
/// to: the items are committed before the request so later turns can see
|
||||
/// why the engine changed course.
|
||||
ContinueWith(Vec<Item>),
|
||||
/// Yield after appending these items to durable engine history.
|
||||
///
|
||||
/// This is for host-mediated pre-request appends that must be visible to
|
||||
/// usage accounting and compaction checks before the current LLM request is
|
||||
/// allowed to proceed.
|
||||
YieldWith(Vec<Item>),
|
||||
/// Cancel with a reason (treated as an error).
|
||||
Cancel(String),
|
||||
/// Yield control to the caller for external processing.
|
||||
///
|
||||
/// The Engine exits the turn loop cleanly with `EngineResult::Yielded`.
|
||||
/// The caller is expected to resume execution later.
|
||||
Yield,
|
||||
}
|
||||
|
||||
/// Action before a tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PreToolAction {
|
||||
/// Proceed with execution.
|
||||
Continue,
|
||||
/// Skip this tool call (do not execute).
|
||||
Skip,
|
||||
/// Do not execute the tool call; commit this synthetic result instead.
|
||||
///
|
||||
/// This preserves provider-visible `tool_use` / `tool_result` pairing
|
||||
/// without aborting the whole turn.
|
||||
SyntheticResult(ToolResult),
|
||||
/// Abort the entire run.
|
||||
Abort(String),
|
||||
/// Pause execution (can be resumed later).
|
||||
Pause,
|
||||
}
|
||||
|
||||
/// Action after a tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PostToolAction {
|
||||
/// Proceed normally.
|
||||
Continue,
|
||||
/// Abort the entire run.
|
||||
Abort(String),
|
||||
}
|
||||
|
||||
/// Action at the end of a turn (when LLM produces no tool calls).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEndAction {
|
||||
/// Turn is finished, return to caller.
|
||||
Finish,
|
||||
/// Continue with additional messages injected into history.
|
||||
ContinueWithMessages(Vec<Item>),
|
||||
/// Pause execution (can be resumed later).
|
||||
Pause,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context Types
|
||||
// =============================================================================
|
||||
|
||||
/// Context for pre-tool-call decisions.
|
||||
pub struct ToolCallInfo {
|
||||
/// Tool call information (modifiable).
|
||||
pub call: ToolCall,
|
||||
/// Tool meta information.
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
/// Response-local execution context for this call.
|
||||
pub context: ToolExecutionContext,
|
||||
}
|
||||
|
||||
/// Context for post-tool-call decisions.
|
||||
pub struct ToolResultInfo {
|
||||
/// Original tool call.
|
||||
pub call: ToolCall,
|
||||
/// Tool execution result (modifiable).
|
||||
pub result: ToolResult,
|
||||
/// Tool meta information.
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
/// Response-local execution context for this call.
|
||||
pub context: ToolExecutionContext,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Interceptor Trait
|
||||
// =============================================================================
|
||||
|
||||
/// Intercepts the Engine execution loop at key decision points.
|
||||
///
|
||||
/// All methods have default implementations that let the Engine
|
||||
/// proceed without intervention. Callers provide richer implementations for
|
||||
/// approval flows, permission checks, etc.
|
||||
#[async_trait]
|
||||
pub trait Interceptor: Send + Sync {
|
||||
/// Called after receiving user input, before adding to history.
|
||||
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
|
||||
PromptAction::Continue
|
||||
}
|
||||
|
||||
/// Items that should be **committed to `engine.history`** just
|
||||
/// before the next LLM request. Returned items are `extend`ed into
|
||||
/// the persistent history (and therefore picked up by the per-turn
|
||||
/// clone that backs the LLM request, plus the usual
|
||||
/// history-persistence path).
|
||||
///
|
||||
/// Use this for inputs that arrive from outside the LLM and need
|
||||
/// to be reflected in the on-disk history — notifications,
|
||||
/// external events, system reminders. Do **not** use
|
||||
/// [`Self::pre_llm_request`] for that purpose: it mutates a
|
||||
/// per-request clone, so any committed assistant response that
|
||||
/// reacts to the injection would have no visible trigger on the
|
||||
/// next turn (or after resume / compaction).
|
||||
///
|
||||
/// `pre_llm_request` remains the right place for purely
|
||||
/// reproducible per-request transformations (pruning, content
|
||||
/// trimming, cache anchors) that depend only on the existing
|
||||
/// history.
|
||||
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Called before each LLM request. The context starts as a clone
|
||||
/// of `engine.history` (after `pending_history_appends` and the
|
||||
/// Engine's own prune projection have been applied).
|
||||
///
|
||||
/// Direct mutations to `context` remain request-local and are not persisted.
|
||||
/// If an interceptor derives a human/model-visible nudge from the current
|
||||
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
||||
/// commits it to history before the request is sent.
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
|
||||
/// Called before each tool is executed.
|
||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||
PreToolAction::Continue
|
||||
}
|
||||
|
||||
/// Called after each tool completes.
|
||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||
PostToolAction::Continue
|
||||
}
|
||||
|
||||
/// Called when a turn ends with no tool calls.
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
TurnEndAction::Finish
|
||||
}
|
||||
|
||||
/// Called when execution is interrupted (abort or cancel).
|
||||
async fn on_abort(&self, _reason: &str) {}
|
||||
}
|
||||
|
||||
/// Default interceptor: no intervention. Engine proceeds through the loop
|
||||
/// without any external control flow decisions.
|
||||
pub(crate) struct DefaultInterceptor;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for DefaultInterceptor {}
|
||||
@@ -0,0 +1,42 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
mod engine;
|
||||
mod handler;
|
||||
mod message;
|
||||
|
||||
pub(crate) mod callback;
|
||||
pub mod event;
|
||||
pub mod interceptor;
|
||||
pub mod llm_client;
|
||||
pub mod providers;
|
||||
pub mod prune;
|
||||
pub mod state;
|
||||
pub mod timeline;
|
||||
pub mod token_counter;
|
||||
pub mod tool;
|
||||
pub mod tool_server;
|
||||
pub mod usage_record;
|
||||
|
||||
pub use agen_macros::{description, tool, tool_registry};
|
||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||
pub use engine::{
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
|
||||
ToolRegistryError,
|
||||
};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use interceptor::Interceptor;
|
||||
pub use message::{ContentPart, Item, Message, Role};
|
||||
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
||||
pub use usage_record::UsageRecord;
|
||||
|
||||
/// Implementation dependencies used by code generated from `agen` macros.
|
||||
///
|
||||
/// This module is not a stable user-facing API. It is public only because macro expansion
|
||||
/// happens in the downstream crate.
|
||||
#[doc(hidden)]
|
||||
pub mod __private {
|
||||
pub use async_trait;
|
||||
pub use schemars;
|
||||
pub use serde;
|
||||
pub use serde_json;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ。
|
||||
//!
|
||||
//! `AuthRequirement` は scheme が宣言する「この scheme はどんな認証を
|
||||
//! 期待するか」のランタイム記述で、設定ファイルや環境変数などから
|
||||
//! [`super::transport::ResolvedAuth`] を組み立てる責務は呼び出し側にある。
|
||||
//!
|
||||
//! リクエスト毎にトークンが変わり得る認証は [`AuthProvider`] trait を
|
||||
//! 実装し、[`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
|
||||
use super::error::ClientError;
|
||||
|
||||
/// `Scheme::required_auth()` が返す認証要件。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthRequirement {
|
||||
/// 認証を行わない(Ollama など)
|
||||
None,
|
||||
/// `Authorization: Bearer <token>` ヘッダ(token は API key 相当)
|
||||
Bearer,
|
||||
/// `x-api-key: <token>` ヘッダ(Anthropic 形式)
|
||||
XApiKey,
|
||||
/// クエリパラメータ `?<name>=<token>`(Gemini 形式)
|
||||
QueryParam { name: &'static str },
|
||||
/// 複合ヘッダ(呼び出し側が [`AuthProvider`] で解決)
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
||||
///
|
||||
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
||||
/// 必要があるケースで使う。実体は呼び出し側に置き、agen は
|
||||
/// trait を知るだけ。
|
||||
///
|
||||
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
||||
/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら
|
||||
/// 実装側でセットすること。
|
||||
#[async_trait]
|
||||
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
|
||||
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! モデル能力メタデータ
|
||||
//!
|
||||
//! `ModelCapability` はモデルが持つ機能差を表現する。scheme は同じでも
|
||||
//! モデルごとに reasoning 可否や prompt caching 方式が違うため、scheme
|
||||
//! から分離して保持する。
|
||||
//!
|
||||
//! 値の供給経路は 2 通り:
|
||||
//! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル)
|
||||
//! 2. `ModelConfig::capability` での明示 override(未知モデル、または上書き)
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// モデル能力メタデータ
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelCapability {
|
||||
pub tool_calling: ToolCallingSupport,
|
||||
pub structured_output: StructuredOutput,
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<ReasoningSupport>,
|
||||
#[serde(default)]
|
||||
pub vision: bool,
|
||||
pub prompt_caching: CacheStrategy,
|
||||
}
|
||||
|
||||
impl ModelCapability {
|
||||
/// 何もサポートしない安全側デフォルト。未知モデルのフォールバック用。
|
||||
pub const fn minimal() -> Self {
|
||||
Self {
|
||||
tool_calling: ToolCallingSupport::None,
|
||||
structured_output: StructuredOutput::None,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ツール呼び出しサポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallingSupport {
|
||||
/// 非サポート
|
||||
None,
|
||||
/// 1 回のレスポンスで 1 ツールのみ
|
||||
Sequential,
|
||||
/// 1 回のレスポンスで複数ツール並行
|
||||
Parallel,
|
||||
}
|
||||
|
||||
/// Structured output サポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StructuredOutput {
|
||||
None,
|
||||
/// `json_object` モード(スキーマなし JSON 強制)
|
||||
JsonObject,
|
||||
/// JSON Schema 指定で構造化出力
|
||||
JsonSchema,
|
||||
}
|
||||
|
||||
/// Reasoning(extended thinking)サポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReasoningSupport {
|
||||
/// OpenAI 形式: `reasoning.effort` (low/medium/high)
|
||||
Effort,
|
||||
/// Anthropic 形式: `thinking.budget_tokens`
|
||||
BudgetTokens,
|
||||
/// 両対応(内部では共通 `ReasoningControl` として扱い、各 scheme で投影)
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Prompt caching 戦略
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CacheStrategy {
|
||||
/// Anthropic: `cache_control` マーカーを明示挿入
|
||||
Explicit { max_breakpoints: u8 },
|
||||
/// それ以外: サーバ側自動 prefix、または未サポート
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Reasoning 制御(共通型、scheme 側で各社形式に投影)。
|
||||
///
|
||||
/// 文字列は provider-native な effort label、数値は provider-native な
|
||||
/// thinking budget token として扱う。どちらか一方だけを型で表現する。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum ReasoningControl {
|
||||
Effort(ReasoningEffort),
|
||||
BudgetTokens(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReasoningEffort {
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
XHigh,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Minimal => "minimal",
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Other(label) => label.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ReasoningEffort {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"minimal" => Self::Minimal,
|
||||
"low" => Self::Low,
|
||||
"medium" => Self::Medium,
|
||||
"high" => Self::High,
|
||||
"xhigh" => Self::XHigh,
|
||||
_ => Self::Other(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ReasoningEffort {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ReasoningEffort {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer).map(Self::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ReasoningControl, ReasoningEffort};
|
||||
|
||||
#[test]
|
||||
fn reasoning_control_deserializes_effort_labels() {
|
||||
let known: ReasoningControl = serde_json::from_str(r#""xhigh""#).unwrap();
|
||||
assert_eq!(known, ReasoningControl::Effort(ReasoningEffort::XHigh));
|
||||
|
||||
let unknown: ReasoningControl = serde_json::from_str(r#""provider-native""#).unwrap();
|
||||
assert_eq!(
|
||||
unknown,
|
||||
ReasoningControl::Effort(ReasoningEffort::Other("provider-native".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_control_deserializes_signed_budget() {
|
||||
let dynamic: ReasoningControl = serde_json::from_str("-1").unwrap();
|
||||
assert_eq!(dynamic, ReasoningControl::BudgetTokens(-1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! LLMクライアント共通trait定義
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::llm_client::{ClientError, Request, RequestConfig, event::Event};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
|
||||
/// 設定に関する警告
|
||||
///
|
||||
/// プロバイダがサポートしていない設定を使用した場合に返される。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigWarning {
|
||||
/// 設定オプション名
|
||||
pub option_name: &'static str,
|
||||
/// 警告メッセージ
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ConfigWarning {
|
||||
/// 新しい警告を作成
|
||||
pub fn unsupported(option_name: &'static str, provider_name: &str) -> Self {
|
||||
Self {
|
||||
option_name,
|
||||
message: format!(
|
||||
"'{}' is not supported by {} and will be ignored",
|
||||
option_name, provider_name
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigWarning {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}: {}", self.option_name, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>;
|
||||
|
||||
/// 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<ResponseStream, ClientError>;
|
||||
|
||||
/// Clone this client into a new `Box<dyn LlmClient>`.
|
||||
///
|
||||
/// Used when a second client instance is needed (e.g. for context
|
||||
/// compaction) without access to the original construction parameters.
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient>;
|
||||
|
||||
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - バリデーション対象の設定
|
||||
///
|
||||
/// # Returns
|
||||
/// サポートされていない設定に対する警告のリスト
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
// デフォルト実装: 全ての設定をサポート
|
||||
let _ = config;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Box<dyn LlmClient> {
|
||||
fn clone(&self) -> Self {
|
||||
self.clone_boxed()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Box<dyn LlmClient>` に対する `LlmClient` の実装
|
||||
///
|
||||
/// これにより、動的ディスパッチを使用するクライアントも `Engine` で利用可能になる。
|
||||
#[async_trait]
|
||||
impl LlmClient for Box<dyn LlmClient> {
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
(**self).stream(request).await
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
(**self).clone_boxed()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
(**self).validate_config(config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! LLMクライアントエラー型
|
||||
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
/// 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,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// A request lifecycle phase exceeded its hard timeout.
|
||||
Timeout {
|
||||
phase: &'static str,
|
||||
timeout: Duration,
|
||||
},
|
||||
/// 設定エラー
|
||||
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::Timeout { phase, timeout } => {
|
||||
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientError {
|
||||
pub fn status(&self) -> Option<u16> {
|
||||
match self {
|
||||
ClientError::Api { status, .. } => *status,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
match self {
|
||||
ClientError::Api { retry_after, .. } => *retry_after,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// transient な失敗としてリトライ対象になるかを判定する。
|
||||
///
|
||||
/// 対象:
|
||||
/// - `Api { status }` のうち 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
|
||||
/// - `Http(reqwest::Error)` のうち `is_connect()` または `is_timeout()`
|
||||
/// - `Timeout { .. }` の lifecycle hard timeout
|
||||
///
|
||||
/// それ以外(Json、Sse、Config、上記以外の Api ステータス)は false。
|
||||
/// SSE 読み出し開始後の失敗は呼び出し側で `Sse` として上に流すため、
|
||||
/// ここで対象外にしておけば自動的に弾かれる。
|
||||
pub fn is_retryable(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api {
|
||||
status: Some(code), ..
|
||||
} => matches!(*code, 408 | 425 | 429 | 500 | 502 | 503 | 504 | 529),
|
||||
ClientError::Api { status: None, .. } => false,
|
||||
ClientError::Timeout { .. } => true,
|
||||
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
||||
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn api_err(status: Option<u16>) -> ClientError {
|
||||
ClientError::Api {
|
||||
status,
|
||||
code: None,
|
||||
message: String::new(),
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_status_codes() {
|
||||
for code in [408u16, 425, 429, 500, 502, 503, 504, 529] {
|
||||
assert!(
|
||||
is_retryable(&api_err(Some(code))),
|
||||
"status {code} should be retryable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_retryable_status_codes() {
|
||||
for code in [400u16, 401, 403, 404, 409, 410, 422, 501] {
|
||||
assert!(
|
||||
!is_retryable(&api_err(Some(code))),
|
||||
"status {code} should not be retryable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_without_status_not_retryable() {
|
||||
assert!(!is_retryable(&api_err(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_timeout_is_retryable() {
|
||||
assert!(is_retryable(&ClientError::Timeout {
|
||||
phase: "stream_open",
|
||||
timeout: Duration::from_secs(30),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_sse_config_not_retryable() {
|
||||
let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
|
||||
assert!(!is_retryable(&ClientError::Json(json_err)));
|
||||
assert!(!is_retryable(&ClientError::Sse("boom".into())));
|
||||
assert!(!is_retryable(&ClientError::Config("boom".into())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! LLMクライアント層のイベント型
|
||||
//!
|
||||
//! 各LLMプロバイダからのストリーミングレスポンスを表現するイベント型。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// =============================================================================
|
||||
// Core Event Types (from llm_client layer)
|
||||
// =============================================================================
|
||||
|
||||
/// LLMからのストリーミングイベント
|
||||
///
|
||||
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
|
||||
/// 統一的に処理されます。
|
||||
///
|
||||
/// # イベントの種類
|
||||
///
|
||||
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
|
||||
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
|
||||
///
|
||||
/// # ブロックのライフサイクル
|
||||
///
|
||||
/// テキスト、thinking、ツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
|
||||
/// の順序でイベントが発生します。thinking の round-trip metadata は
|
||||
/// `BlockStop.reasoning` に載ります。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Event {
|
||||
/// ハートビート
|
||||
Ping(PingEvent),
|
||||
/// トークン使用量
|
||||
Usage(UsageEvent),
|
||||
/// ストリームのステータス変化
|
||||
Status(StatusEvent),
|
||||
/// エラー発生
|
||||
Error(ErrorEvent),
|
||||
/// Scheme が生成内容として解釈しない未対応 SSE イベント。
|
||||
///
|
||||
/// stream trace 用の観測イベントであり、timeline / history には反映しない。
|
||||
UnhandledSse(UnhandledSseEvent),
|
||||
|
||||
/// ブロック開始(テキスト、ツール使用等)
|
||||
BlockStart(BlockStart),
|
||||
/// ブロックの差分データ
|
||||
BlockDelta(BlockDelta),
|
||||
/// ブロック正常終了
|
||||
BlockStop(BlockStop),
|
||||
/// ブロック中断
|
||||
BlockAbort(BlockAbort),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Meta Events
|
||||
// =============================================================================
|
||||
|
||||
/// Pingイベント(ハートビート)
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct PingEvent {
|
||||
pub timestamp: Option<u64>,
|
||||
}
|
||||
|
||||
/// 使用量イベント
|
||||
///
|
||||
/// プロバイダから受信した 1 LLM リクエスト分のトークン会計。
|
||||
/// 各 scheme で正規化され、フィールドの意味は全プロバイダ共通:
|
||||
///
|
||||
/// - `input_tokens` は **送信した prompt prefix 全体の占有量**(プロンプト全長)。
|
||||
/// キャッシュヒット分も含まれる。Anthropic は raw API では非キャッシュ分のみを
|
||||
/// `input_tokens` として返すため、`AnthropicScheme::convert_usage` で
|
||||
/// `cache_read + cache_creation` を加算してこの規約に揃えている。
|
||||
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` は上記の内訳で、
|
||||
/// 料金会計用。占有量からは差し引かない。
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct UsageEvent {
|
||||
/// 送信した prompt prefix の総トークン数(占有量、キャッシュ込み)
|
||||
pub input_tokens: Option<u64>,
|
||||
/// このリクエストで生成された出力トークン数
|
||||
pub output_tokens: Option<u64>,
|
||||
/// `input_tokens + output_tokens`
|
||||
pub total_tokens: Option<u64>,
|
||||
/// `input_tokens` のうちキャッシュから読まれた分(割引料金)
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
/// `input_tokens` のうちこのリクエストでキャッシュに書かれた分(割増料金、Anthropic)
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// ステータスイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StatusEvent {
|
||||
pub status: ResponseStatus,
|
||||
}
|
||||
|
||||
/// レスポンスステータス
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ResponseStatus {
|
||||
/// ストリーム開始
|
||||
Started,
|
||||
/// 正常完了
|
||||
Completed,
|
||||
/// キャンセルされた
|
||||
Cancelled,
|
||||
/// エラー発生
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// エラーイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ErrorEvent {
|
||||
pub code: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 未対応 SSE イベントの観測用メタイベント。
|
||||
///
|
||||
/// `data_preview` は provider から受け取った raw SSE data の bounded preview、
|
||||
/// `data_len` は preview 前の raw data byte length。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnhandledSseEvent {
|
||||
pub provider: String,
|
||||
pub event_type: String,
|
||||
pub data_preview: String,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Block Types
|
||||
// =============================================================================
|
||||
|
||||
/// ブロックの種別
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum BlockType {
|
||||
/// テキスト生成
|
||||
Text,
|
||||
/// 思考 (Claude Extended Thinking等)
|
||||
Thinking,
|
||||
/// ツール呼び出し
|
||||
ToolUse,
|
||||
/// ツール結果
|
||||
ToolResult,
|
||||
}
|
||||
|
||||
/// ブロック開始イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStart {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// ブロック固有のメタデータ
|
||||
pub metadata: BlockMetadata,
|
||||
}
|
||||
|
||||
impl BlockStart {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロックのメタデータ
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BlockMetadata {
|
||||
Text,
|
||||
Thinking,
|
||||
ToolUse { id: String, name: String },
|
||||
ToolResult { tool_use_id: String },
|
||||
}
|
||||
|
||||
/// ブロックデルタイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockDelta {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// デルタの内容
|
||||
pub delta: DeltaContent,
|
||||
}
|
||||
|
||||
/// デルタの内容
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DeltaContent {
|
||||
/// テキストデルタ
|
||||
Text(String),
|
||||
/// 思考デルタ
|
||||
Thinking(String),
|
||||
/// ツール引数のJSON部分文字列
|
||||
InputJson(String),
|
||||
}
|
||||
|
||||
impl DeltaContent {
|
||||
/// デルタのブロック種別を取得
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
match self {
|
||||
DeltaContent::Text(_) => BlockType::Text,
|
||||
DeltaContent::Thinking(_) => BlockType::Thinking,
|
||||
DeltaContent::InputJson(_) => BlockType::ToolUse,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロック停止イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStop {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// 停止理由
|
||||
pub stop_reason: Option<StopReason>,
|
||||
/// Thinking block の停止時に確定した reasoning round-trip metadata。
|
||||
///
|
||||
/// `None` の Thinking block は live streaming / trace 用で、history に
|
||||
/// `Item::Reasoning` として永続化しない。`Some` の場合は block lifecycle
|
||||
/// が永続化の authoritative source になる。
|
||||
pub reasoning: Option<ReasoningBlockData>,
|
||||
}
|
||||
|
||||
impl BlockStop {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロック中断イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockAbort {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// 中断理由
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl BlockAbort {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
/// Thinking block stop で確定した reasoning material。
|
||||
///
|
||||
/// `Item::Reasoning` の round-trip に必要な provider material を保持する。
|
||||
/// `text` は deltas から収集した本文を上書きするために使う(metadata-only
|
||||
/// reasoning block や provider completion event で全文が届くケース)。
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ReasoningBlockData {
|
||||
/// scheme 側で観測した item id(OpenAI Responses の `id`)。
|
||||
pub id: Option<String>,
|
||||
/// reasoning 本体テキスト。`None` の場合は block delta 収集結果を使う。
|
||||
pub text: Option<String>,
|
||||
/// summary (OpenAI Responses の `summary_text[]`)。他 scheme は空。
|
||||
pub summary: Vec<String>,
|
||||
/// 暗号化された opaque blob(Anthropic `redacted_thinking.data` /
|
||||
/// OpenAI Responses `encrypted_content`)。
|
||||
pub encrypted_content: Option<String>,
|
||||
/// Anthropic extended thinking signature。round-trip 必須。
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// 停止理由
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum StopReason {
|
||||
/// 自然終了
|
||||
EndTurn,
|
||||
/// 最大トークン数到達
|
||||
MaxTokens,
|
||||
/// ストップシーケンス到達
|
||||
StopSequence,
|
||||
/// ツール使用
|
||||
ToolUse,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Builder / Factory helpers
|
||||
// =============================================================================
|
||||
|
||||
impl Event {
|
||||
/// テキストブロック開始イベントを作成
|
||||
pub fn text_block_start(index: usize) -> Self {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
metadata: BlockMetadata::Text,
|
||||
})
|
||||
}
|
||||
|
||||
/// テキストデルタイベントを作成
|
||||
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
index,
|
||||
delta: DeltaContent::Text(text.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// テキストブロック停止イベントを作成
|
||||
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
|
||||
Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール使用ブロック開始イベントを作成
|
||||
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール引数デルタイベントを作成
|
||||
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
index,
|
||||
delta: DeltaContent::InputJson(json.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール使用ブロック停止イベントを作成
|
||||
pub fn tool_use_stop(index: usize) -> Self {
|
||||
Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
stop_reason: Some(StopReason::ToolUse),
|
||||
reasoning: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 使用量イベントを作成
|
||||
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
|
||||
Event::Usage(UsageEvent {
|
||||
input_tokens: Some(input_tokens),
|
||||
output_tokens: Some(output_tokens),
|
||||
total_tokens: Some(input_tokens + output_tokens),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pingイベントを作成
|
||||
pub fn ping() -> Self {
|
||||
Event::Ping(PingEvent { timestamp: None })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! LLMクライアント層
|
||||
//!
|
||||
//! 各LLMプロバイダと通信し、統一された[`Event`]
|
||||
//! ストリームを出力します。
|
||||
//!
|
||||
//! # サポートするプロバイダ
|
||||
//!
|
||||
//! - Anthropic (Claude)
|
||||
//! - OpenAI (GPT-4, etc.)
|
||||
//! - Google (Gemini)
|
||||
//! - Ollama (ローカルLLM)
|
||||
//!
|
||||
//! # アーキテクチャ
|
||||
//!
|
||||
//! - [`LlmClient`] - プロバイダ共通のtrait
|
||||
//! - `providers`: プロバイダ固有のクライアント実装
|
||||
//! - `scheme`: APIスキーマ(リクエスト/レスポンス変換)
|
||||
|
||||
pub mod auth;
|
||||
pub mod capability;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod types;
|
||||
|
||||
pub mod retry;
|
||||
pub mod scheme;
|
||||
pub mod transport;
|
||||
|
||||
pub use auth::*;
|
||||
pub use capability::*;
|
||||
pub use client::*;
|
||||
pub use error::*;
|
||||
pub use event::*;
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,104 @@
|
||||
//! LLM response stream を開く前の transient error 向けリトライポリシー。
|
||||
//!
|
||||
//! `LlmClient::stream` の open error に対して `is_retryable` を見て
|
||||
//! retry / backoff / cancellation をまとめて管理する。
|
||||
//! SSE 読み出し開始後の失敗は対象外。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
||||
///
|
||||
/// `Default` は agen 全体の固定値を返す。呼び出し側からの上書きが
|
||||
/// 必要になったら拡張する。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryPolicy {
|
||||
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から
|
||||
/// フルジッターで実際の wait を抽選する。
|
||||
pub base: Duration,
|
||||
/// 1 回あたりの wait の上限。
|
||||
pub cap: Duration,
|
||||
/// 試行の合計回数(初回 + リトライ)。`1` ならリトライしない。
|
||||
pub max_attempts: u32,
|
||||
/// 初回送信開始からの累積タイムアウト。これを超える wait は打ち切る。
|
||||
pub total_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for RetryPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: Duration::from_millis(500),
|
||||
cap: Duration::from_secs(10),
|
||||
max_attempts: 4,
|
||||
total_timeout: Duration::from_secs(40),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RetryPolicy {
|
||||
/// `attempt` 回目の失敗(0-indexed)後に待つ時間を返す。
|
||||
/// `Retry-After` で上書きしたい場合は呼び出さず、その値をそのまま使う。
|
||||
pub fn backoff(&self, attempt: u32) -> Duration {
|
||||
let shift = attempt.min(20);
|
||||
let base_nanos = self.base.as_nanos() as u64;
|
||||
let exp_nanos = base_nanos.saturating_mul(1u64 << shift);
|
||||
let cap_nanos = self.cap.as_nanos() as u64;
|
||||
let upper = exp_nanos.min(cap_nanos);
|
||||
Duration::from_nanos(jitter_nanos(upper))
|
||||
}
|
||||
}
|
||||
|
||||
/// `[0, max_nanos]` から擬似乱数的に 1 つ取り出す。`SystemTime` の
|
||||
/// 下位ビットを splitmix64 で攪拌するだけの軽量実装で、暗号的乱数性は
|
||||
/// 持たないがフルジッターのぶつかり回避には十分。
|
||||
fn jitter_nanos(max_nanos: u64) -> u64 {
|
||||
if max_nanos == 0 {
|
||||
return 0;
|
||||
}
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let mut x = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^= x >> 31;
|
||||
x % (max_nanos + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_policy_values() {
|
||||
let p = RetryPolicy::default();
|
||||
assert_eq!(p.base, Duration::from_millis(500));
|
||||
assert_eq!(p.cap, Duration::from_secs(10));
|
||||
assert_eq!(p.max_attempts, 4);
|
||||
assert_eq!(p.total_timeout, Duration::from_secs(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_respects_cap() {
|
||||
let p = RetryPolicy::default();
|
||||
for attempt in 0..30u32 {
|
||||
assert!(
|
||||
p.backoff(attempt) <= p.cap,
|
||||
"attempt {attempt} exceeded cap",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_zero_when_base_zero() {
|
||||
let p = RetryPolicy {
|
||||
base: Duration::ZERO,
|
||||
cap: Duration::from_secs(10),
|
||||
max_attempts: 4,
|
||||
total_timeout: Duration::from_secs(30),
|
||||
};
|
||||
for attempt in 0..5 {
|
||||
assert_eq!(p.backoff(attempt), Duration::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Anthropic scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`claude-*` など)は client construction layer
|
||||
//! の責務。ここでは未知モデルでも「この wire で
|
||||
//! 安全に送れる最小共通項」を返すだけに留める。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability。
|
||||
///
|
||||
/// Ollama の `/v1/messages` 流用を想定して `cache_control` を送らない
|
||||
/// `CacheStrategy::Auto` にする。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
//! Anthropic SSEイベントパース
|
||||
//!
|
||||
//! Anthropic Messages APIのSSEイベントをパースし、統一Event型に変換
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{
|
||||
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, ErrorEvent,
|
||||
Event, PingEvent, ResponseStatus, StatusEvent, UsageEvent,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::AnthropicScheme;
|
||||
use super::scheme_impl::{AnthropicState, PendingThinking};
|
||||
|
||||
/// 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 {
|
||||
#[serde(default)]
|
||||
thinking: String,
|
||||
/// 非ストリーミングレスポンス由来の初期 signature(通常はストリームでは
|
||||
/// 空 → `signature_delta` で埋まる)。
|
||||
#[serde(default)]
|
||||
signature: Option<String>,
|
||||
},
|
||||
#[serde(rename = "redacted_thinking")]
|
||||
RedactedThinking {
|
||||
/// 暗号化された opaque blob。signature ではなく、まるごと
|
||||
/// `redacted_thinking.data` として送り返す必要がある。
|
||||
#[serde(default)]
|
||||
data: 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が必要だが、AnthropicはStopイベントに含めない
|
||||
// Timeline層がBlockStartを追跡して正しいblock_typeを知る
|
||||
Ok(Some(Event::BlockStop(BlockStop {
|
||||
index: event.index,
|
||||
block_type: BlockType::Text, // Timeline層で上書きされる
|
||||
stop_reason: None,
|
||||
reasoning: 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 { .. } | ContentBlock::RedactedThinking { .. } => {
|
||||
(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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// state を持ち回す上位パース。
|
||||
///
|
||||
/// `parse_event` の単発 Event に加えて、以下を行う:
|
||||
/// - `content_block_stop` の `block_type` を直前の Start 値で書き戻す
|
||||
/// - `thinking` / `redacted_thinking` ブロックの本体・signature・data を
|
||||
/// `state.pending_thinking` に蓄積し、`content_block_stop` の Thinking
|
||||
/// BlockStop metadata に載せる
|
||||
/// - `signature_delta` を蓄積(Stream channel には流さず、reasoning metadata
|
||||
/// にだけ反映する)
|
||||
pub(crate) fn parse_with_state(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut AnthropicState,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let Some(parsed_event_type) = AnthropicEventType::parse(event_type) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// signature_delta はストリーム表示には流さず、state にだけ蓄積。
|
||||
// それ以外は parse_event で標準 Event 化する。
|
||||
let mut emitted: Vec<Event> = Vec::new();
|
||||
|
||||
match parsed_event_type {
|
||||
AnthropicEventType::ContentBlockStart => {
|
||||
let raw: ContentBlockStartEvent = serde_json::from_str(data)?;
|
||||
state.current_block_type = Some(match &raw.content_block {
|
||||
ContentBlock::Text { .. } => BlockType::Text,
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
|
||||
BlockType::Thinking
|
||||
}
|
||||
ContentBlock::ToolUse { .. } => BlockType::ToolUse,
|
||||
});
|
||||
match &raw.content_block {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
state.pending_thinking = Some(PendingThinking {
|
||||
text: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
redacted_data: None,
|
||||
});
|
||||
}
|
||||
ContentBlock::RedactedThinking { data: blob } => {
|
||||
state.pending_thinking = Some(PendingThinking {
|
||||
text: String::new(),
|
||||
signature: None,
|
||||
redacted_data: Some(blob.clone()),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
emitted.push(self.convert_block_start(&raw));
|
||||
}
|
||||
AnthropicEventType::ContentBlockDelta => {
|
||||
let raw: ContentBlockDeltaEvent = serde_json::from_str(data)?;
|
||||
match &raw.delta {
|
||||
DeltaBlock::ThinkingDelta { thinking } => {
|
||||
if let Some(pending) = state.pending_thinking.as_mut() {
|
||||
pending.text.push_str(thinking);
|
||||
}
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::Thinking(thinking.clone()),
|
||||
}));
|
||||
}
|
||||
DeltaBlock::SignatureDelta { signature } => {
|
||||
if let Some(pending) = state.pending_thinking.as_mut() {
|
||||
// 通常 1 回しか来ないが、複数 fragment 来ても連結しておく
|
||||
match &mut pending.signature {
|
||||
Some(acc) => acc.push_str(signature),
|
||||
None => pending.signature = Some(signature.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
DeltaBlock::TextDelta { text } => {
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::Text(text.clone()),
|
||||
}));
|
||||
}
|
||||
DeltaBlock::InputJsonDelta { partial_json } => {
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::InputJson(partial_json.clone()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnthropicEventType::ContentBlockStop => {
|
||||
let raw: ContentBlockStopEvent = serde_json::from_str(data)?;
|
||||
let block_type = state.current_block_type.take().unwrap_or(BlockType::Text);
|
||||
let reasoning = if matches!(block_type, BlockType::Thinking) {
|
||||
state
|
||||
.pending_thinking
|
||||
.take()
|
||||
.map(PendingThinking::into_reasoning)
|
||||
} else {
|
||||
state.pending_thinking.take();
|
||||
None
|
||||
};
|
||||
emitted.push(Event::BlockStop(BlockStop {
|
||||
index: raw.index,
|
||||
block_type,
|
||||
stop_reason: None,
|
||||
reasoning,
|
||||
}));
|
||||
}
|
||||
// 残りは state を必要としない。既存 parse_event に委譲。
|
||||
_ => {
|
||||
if let Some(event) = self.parse_event(event_type, data)? {
|
||||
emitted.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(emitted)
|
||||
}
|
||||
|
||||
fn convert_usage(&self, usage: &UsageData) -> UsageEvent {
|
||||
// Anthropic の `input_tokens` は **キャッシュ外** の入力トークンのみで、
|
||||
// プロンプト全長は input_tokens + cache_read + cache_creation。
|
||||
// UsageEvent の `input_tokens` には「占有量(プロンプト全長)」を載せる
|
||||
// 規約に合わせて、ここでキャッシュ分を足し込む。
|
||||
// cache_read_input_tokens / cache_creation_input_tokens は内訳として
|
||||
// 別フィールドに残るので、料金計算側で `input - cache_read - cache_creation`
|
||||
// により非キャッシュ入力分は逆算可能。
|
||||
let raw_input = usage.input_tokens.unwrap_or(0);
|
||||
let cache_read = usage.cache_read_input_tokens.unwrap_or(0);
|
||||
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||
let input_total = raw_input + cache_read + cache_creation;
|
||||
let output = usage.output_tokens.unwrap_or(0);
|
||||
|
||||
UsageEvent {
|
||||
input_tokens: usage.input_tokens.map(|_| input_total),
|
||||
output_tokens: usage.output_tokens,
|
||||
total_tokens: Some(input_total + 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) => {
|
||||
// キャッシュなしなので input_total = raw_input = 10
|
||||
assert_eq!(u.input_tokens, Some(10));
|
||||
}
|
||||
_ => panic!("Expected Usage event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_usage_includes_cache_in_input_total() {
|
||||
// Anthropic の input_tokens はキャッシュ外のみで、占有量は
|
||||
// input + cache_read + cache_creation。
|
||||
// UsageEvent.input_tokens は占有量に正規化される。
|
||||
let scheme = AnthropicScheme::new();
|
||||
let usage = UsageData {
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(50),
|
||||
cache_read_input_tokens: Some(800),
|
||||
cache_creation_input_tokens: Some(200),
|
||||
};
|
||||
let event = scheme.convert_usage(&usage);
|
||||
// 100 + 800 + 200 = 1100
|
||||
assert_eq!(event.input_tokens, Some(1100));
|
||||
assert_eq!(event.cache_read_input_tokens, Some(800));
|
||||
assert_eq!(event.cache_creation_input_tokens, Some(200));
|
||||
assert_eq!(event.total_tokens, Some(1150));
|
||||
}
|
||||
|
||||
#[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 thinking_block_stop_carries_reasoning_with_signature() {
|
||||
// thinking ブロックが完了したら reasoning metadata に text+signature が乗ること
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
let evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(evs[0], Event::BlockStart(_)));
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hello "}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"world"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG-XYZ"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stop_evs.len(), 1);
|
||||
let Event::BlockStop(stop) = &stop_evs[0] else {
|
||||
panic!("expected BlockStop, got {:?}", stop_evs[0]);
|
||||
};
|
||||
let reasoning = stop.reasoning.as_ref().expect("reasoning metadata");
|
||||
assert_eq!(reasoning.text.as_deref(), Some("hello world"));
|
||||
assert_eq!(reasoning.signature.as_deref(), Some("SIG-XYZ"));
|
||||
assert!(reasoning.encrypted_content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_thinking_stop_carries_reasoning_with_data() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"opaque-blob"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stop_evs.len(), 1);
|
||||
let Event::BlockStop(stop) = &stop_evs[0] else {
|
||||
panic!("expected BlockStop");
|
||||
};
|
||||
let reasoning = stop.reasoning.as_ref().expect("reasoning metadata");
|
||||
assert_eq!(reasoning.text.as_deref(), Some(""));
|
||||
assert!(reasoning.signature.is_none());
|
||||
assert_eq!(reasoning.encrypted_content.as_deref(), Some("opaque-blob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_block_stop_has_no_reasoning_metadata() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stop_evs.len(), 1);
|
||||
let Event::BlockStop(stop) = &stop_evs[0] else {
|
||||
panic!("expected BlockStop");
|
||||
};
|
||||
assert!(stop.reasoning.is_none());
|
||||
}
|
||||
|
||||
#[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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Anthropic Messages API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::AnthropicState;
|
||||
|
||||
/// Anthropicスキーマ
|
||||
///
|
||||
/// Anthropic Messages APIのリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone)]
|
||||
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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
//! `impl Scheme for AnthropicScheme`
|
||||
//!
|
||||
//! Anthropic Messages API の wire 表現に必要な URL・ヘッダ・SSE パース・
|
||||
//! リクエスト body 生成を共通 `Scheme` trait にぶら下げる。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
event::{BlockType, Event, ReasoningBlockData},
|
||||
scheme::Scheme,
|
||||
types::Request,
|
||||
};
|
||||
|
||||
use super::AnthropicScheme;
|
||||
|
||||
/// Anthropic の SSE パースで必要な状態。
|
||||
///
|
||||
/// 1. `content_block_stop` イベントは `block_type` を持たない仕様なので、
|
||||
/// 直前の `content_block_start` で観測した `block_type` を保持して
|
||||
/// `BlockStop` に書き戻す。
|
||||
/// 2. `thinking` ブロック中の `thinking_delta` テキストと `signature_delta`
|
||||
/// 署名、および `redacted_thinking` ブロックの `data` を蓄積し、
|
||||
/// `content_block_stop` の Thinking block metadata として返す
|
||||
/// (round-trip 永続化のため)。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AnthropicState {
|
||||
pub(crate) current_block_type: Option<BlockType>,
|
||||
pub(crate) pending_thinking: Option<PendingThinking>,
|
||||
}
|
||||
|
||||
/// 1 つの `thinking` または `redacted_thinking` content_block の蓄積バッファ。
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PendingThinking {
|
||||
pub(crate) text: String,
|
||||
pub(crate) signature: Option<String>,
|
||||
pub(crate) redacted_data: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingThinking {
|
||||
pub(crate) fn into_reasoning(self) -> ReasoningBlockData {
|
||||
ReasoningBlockData {
|
||||
id: None,
|
||||
text: Some(self.text),
|
||||
summary: Vec::new(),
|
||||
encrypted_content: self.redacted_data,
|
||||
signature: self.signature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scheme for AnthropicScheme {
|
||||
type State = AnthropicState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.anthropic.com"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/v1/messages".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
// Ollama の `/v1/messages` 互換では認証が要らないが、それは
|
||||
// `AuthRef::None` + `build_headers` 側の「ResolvedAuth::None
|
||||
// なら何もしない」分岐で吸収する(`accepts` 判定で弾かれない
|
||||
// よう、現状は XApiKey を要求しつつ、None 側でもパスするよう
|
||||
// にする戦略)。
|
||||
AuthRequirement::XApiKey
|
||||
}
|
||||
|
||||
fn additional_headers(&self) -> Vec<(&'static str, String)> {
|
||||
let mut headers = vec![("anthropic-version", self.api_version.clone())];
|
||||
if self.fine_grained_tool_streaming {
|
||||
headers.push((
|
||||
"anthropic-beta",
|
||||
"fine-grained-tool-streaming-2025-05-14".to_string(),
|
||||
));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&req).expect("AnthropicRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
self.parse_with_state(event_type, data, state)
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Gemini scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は
|
||||
//! client construction layer の責務。ここでは wire の
|
||||
//! 保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability(未知モデル / 未明示モデル用)。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Gemini SSEイベントパース
|
||||
//!
|
||||
//! Google Gemini APIのSSEイベントをパースし、統一Event型に変換
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{BlockMetadata, BlockStart, BlockStop, BlockType, Event, StopReason, UsageEvent},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
// ============================================================================
|
||||
// SSEイベントのJSON構造
|
||||
// ============================================================================
|
||||
|
||||
/// Gemini GenerateContentResponse (ストリーミングチャンク)
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateContentResponse {
|
||||
/// 候補
|
||||
pub candidates: Option<Vec<Candidate>>,
|
||||
/// 使用量メタデータ
|
||||
pub usage_metadata: Option<UsageMetadata>,
|
||||
/// プロンプトフィードバック
|
||||
pub prompt_feedback: Option<PromptFeedback>,
|
||||
/// モデルバージョン
|
||||
pub model_version: Option<String>,
|
||||
}
|
||||
|
||||
/// 候補
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Candidate {
|
||||
/// コンテンツ
|
||||
pub content: Option<CandidateContent>,
|
||||
/// 完了理由
|
||||
pub finish_reason: Option<String>,
|
||||
/// インデックス
|
||||
pub index: Option<usize>,
|
||||
/// 安全性評価
|
||||
pub safety_ratings: Option<Vec<SafetyRating>>,
|
||||
}
|
||||
|
||||
/// 候補コンテンツ
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct CandidateContent {
|
||||
/// パーツ
|
||||
pub parts: Option<Vec<CandidatePart>>,
|
||||
/// ロール
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
/// 候補パーツ
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CandidatePart {
|
||||
/// テキスト
|
||||
pub text: Option<String>,
|
||||
/// 関数呼び出し
|
||||
pub function_call: Option<FunctionCall>,
|
||||
}
|
||||
|
||||
/// 関数呼び出し
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct FunctionCall {
|
||||
/// 関数名
|
||||
pub name: String,
|
||||
/// 引数
|
||||
pub args: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 使用量メタデータ
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UsageMetadata {
|
||||
/// プロンプトトークン数
|
||||
pub prompt_token_count: Option<u64>,
|
||||
/// 候補トークン数
|
||||
pub candidates_token_count: Option<u64>,
|
||||
/// 合計トークン数
|
||||
pub total_token_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// プロンプトフィードバック
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PromptFeedback {
|
||||
/// ブロック理由
|
||||
pub block_reason: Option<String>,
|
||||
/// 安全性評価
|
||||
pub safety_ratings: Option<Vec<SafetyRating>>,
|
||||
}
|
||||
|
||||
/// 安全性評価
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct SafetyRating {
|
||||
/// カテゴリ
|
||||
pub category: Option<String>,
|
||||
/// 確率
|
||||
pub probability: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// イベント変換
|
||||
// ============================================================================
|
||||
|
||||
impl GeminiScheme {
|
||||
/// SSEデータをEvent型に変換
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - SSEイベントデータJSON文字列
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Some(Vec<Event>))` - 変換成功
|
||||
/// * `Ok(None)` - イベントを無視
|
||||
/// * `Err(ClientError)` - パースエラー
|
||||
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
// データが空または無効な場合はスキップ
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response: GenerateContentResponse =
|
||||
serde_json::from_str(data).map_err(|e| ClientError::Api {
|
||||
status: None,
|
||||
code: Some("parse_error".to_string()),
|
||||
message: format!("Failed to parse Gemini SSE data: {} -> {}", e, data),
|
||||
retry_after: None,
|
||||
})?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
// 使用量メタデータ
|
||||
if let Some(usage) = response.usage_metadata {
|
||||
events.push(self.convert_usage(&usage));
|
||||
}
|
||||
|
||||
// 候補を処理
|
||||
if let Some(candidates) = response.candidates {
|
||||
for candidate in candidates {
|
||||
let candidate_index = candidate.index.unwrap_or(0);
|
||||
|
||||
if let Some(content) = candidate.content {
|
||||
if let Some(parts) = content.parts {
|
||||
for (part_index, part) in parts.iter().enumerate() {
|
||||
// テキストデルタ
|
||||
if let Some(text) = &part.text {
|
||||
if !text.is_empty() {
|
||||
// Geminiは明示的なBlockStartを送らないため、
|
||||
// TextDeltaを直接送る(Timelineが暗黙的に開始を処理)
|
||||
events.push(Event::text_delta(part_index, text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// 関数呼び出し
|
||||
if let Some(function_call) = &part.function_call {
|
||||
// 関数呼び出しの開始
|
||||
// Geminiでは関数呼び出しは一度に送られることが多い
|
||||
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
|
||||
|
||||
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
|
||||
let function_id = format!("call_{}", function_call.name);
|
||||
|
||||
events.push(Event::BlockStart(BlockStart {
|
||||
index: candidate_index * 10 + part_index, // 複合インデックス
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: function_id,
|
||||
name: function_call.name.clone(),
|
||||
},
|
||||
}));
|
||||
|
||||
// 引数がある場合はデルタとして送る
|
||||
if let Some(args) = &function_call.args {
|
||||
let args_str = serde_json::to_string(args).unwrap_or_default();
|
||||
if !args_str.is_empty() && args_str != "null" {
|
||||
events.push(Event::tool_input_delta(
|
||||
candidate_index * 10 + part_index,
|
||||
args_str,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 完了理由
|
||||
if let Some(finish_reason) = candidate.finish_reason {
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"STOP" => Some(StopReason::EndTurn),
|
||||
"MAX_TOKENS" => Some(StopReason::MaxTokens),
|
||||
"SAFETY" | "RECITATION" | "OTHER" => Some(StopReason::EndTurn),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// テキストブロックの停止
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index: candidate_index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(events))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_usage(&self, usage: &UsageMetadata) -> Event {
|
||||
Event::Usage(UsageEvent {
|
||||
input_tokens: usage.prompt_token_count,
|
||||
output_tokens: usage.candidates_token_count,
|
||||
total_tokens: usage.total_token_count,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::event::DeltaContent;
|
||||
|
||||
#[test]
|
||||
fn test_parse_text_response() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data =
|
||||
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
assert_eq!(delta.index, 0);
|
||||
if let DeltaContent::Text(text) = &delta.delta {
|
||||
assert_eq!(text, "Hello");
|
||||
} else {
|
||||
panic!("Expected text delta");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected BlockDelta");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_metadata() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// Usageイベントが含まれるはず
|
||||
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
|
||||
assert!(usage_event.is_some());
|
||||
|
||||
if let Event::Usage(usage) = usage_event.unwrap() {
|
||||
assert_eq!(usage.input_tokens, Some(10));
|
||||
assert_eq!(usage.output_tokens, Some(5));
|
||||
assert_eq!(usage.total_tokens, Some(15));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_function_call() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// BlockStartイベントがあるはず
|
||||
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
|
||||
assert!(start_event.is_some());
|
||||
|
||||
if let Event::BlockStart(start) = start_event.unwrap() {
|
||||
assert_eq!(start.block_type, BlockType::ToolUse);
|
||||
if let BlockMetadata::ToolUse { id: _, name } = &start.metadata {
|
||||
assert_eq!(name, "get_weather");
|
||||
} else {
|
||||
panic!("Expected ToolUse metadata");
|
||||
}
|
||||
}
|
||||
|
||||
// 引数デルタもあるはず
|
||||
let delta_event = events.iter().find(|e| {
|
||||
if let Event::BlockDelta(d) = e {
|
||||
matches!(d.delta, DeltaContent::InputJson(_))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
assert!(delta_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_finish_reason() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// BlockStopイベントがあるはず
|
||||
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
|
||||
assert!(stop_event.is_some());
|
||||
|
||||
if let Event::BlockStop(stop) = stop_event.unwrap() {
|
||||
assert_eq!(stop.stop_reason, Some(StopReason::EndTurn));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_data() {
|
||||
let scheme = GeminiScheme::new();
|
||||
assert!(scheme.parse_event("").unwrap().is_none());
|
||||
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Google Gemini API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// Geminiスキーマ
|
||||
///
|
||||
/// Google Gemini APIのリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GeminiScheme {
|
||||
/// ストリーミング関数呼び出し引数を有効にするか
|
||||
pub stream_function_call_arguments: bool,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// 新しいスキーマを作成
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// ストリーミング関数呼び出し引数を有効/無効にする
|
||||
pub fn with_stream_function_call_arguments(mut self, enabled: bool) -> Self {
|
||||
self.stream_function_call_arguments = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
//! Gemini Request Builder
|
||||
//!
|
||||
//! Converts Open Responses native Item model to Google Gemini API format.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
||||
};
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
/// Gemini API request body
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiRequest {
|
||||
/// Contents (conversation history)
|
||||
pub contents: Vec<GeminiContent>,
|
||||
/// System instruction
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_instruction: Option<GeminiContent>,
|
||||
/// Tool definitions
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<GeminiTool>,
|
||||
/// Tool config
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_config: Option<GeminiToolConfig>,
|
||||
/// Generation config
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generation_config: Option<GeminiGenerationConfig>,
|
||||
}
|
||||
|
||||
/// Gemini content
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiContent {
|
||||
/// Role
|
||||
pub role: String,
|
||||
/// Parts
|
||||
pub parts: Vec<GeminiPart>,
|
||||
}
|
||||
|
||||
/// Gemini part
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum GeminiPart {
|
||||
/// Text part
|
||||
Text { text: String },
|
||||
/// Function call part
|
||||
FunctionCall {
|
||||
#[serde(rename = "functionCall")]
|
||||
function_call: GeminiFunctionCall,
|
||||
},
|
||||
/// Function response part
|
||||
FunctionResponse {
|
||||
#[serde(rename = "functionResponse")]
|
||||
function_response: GeminiFunctionResponse,
|
||||
},
|
||||
}
|
||||
|
||||
/// Gemini function call
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionCall {
|
||||
pub name: String,
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
/// Gemini function response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionResponse {
|
||||
pub name: String,
|
||||
pub response: GeminiFunctionResponseContent,
|
||||
}
|
||||
|
||||
/// Gemini function response content
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionResponseContent {
|
||||
pub name: String,
|
||||
pub content: Value,
|
||||
}
|
||||
|
||||
/// Gemini tool definition
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiTool {
|
||||
/// Function declarations
|
||||
pub function_declarations: Vec<GeminiFunctionDeclaration>,
|
||||
}
|
||||
|
||||
/// Gemini function declaration
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionDeclaration {
|
||||
/// Function name
|
||||
pub name: String,
|
||||
/// Description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Parameter schema
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Gemini tool config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiToolConfig {
|
||||
/// Function calling config
|
||||
pub function_calling_config: GeminiFunctionCallingConfig,
|
||||
}
|
||||
|
||||
/// Gemini function calling config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiFunctionCallingConfig {
|
||||
/// Mode: AUTO, ANY, NONE
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
/// Enable streaming function call arguments
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_function_call_arguments: Option<bool>,
|
||||
}
|
||||
|
||||
/// Gemini generation config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiGenerationConfig {
|
||||
/// Max output tokens
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
/// Temperature
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// Top P
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// Top K
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_k: Option<u32>,
|
||||
/// Stop sequences
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// Thinking / reasoning 設定(Gemini 2.5 以降)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_config: Option<GeminiThinkingConfig>,
|
||||
}
|
||||
|
||||
/// Gemini thinking config (gemini-2.5 以降)
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiThinkingConfig {
|
||||
/// Token budget for thinking. `-1` means dynamic.
|
||||
pub thinking_budget: i32,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// Build Gemini request from Request
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> GeminiRequest {
|
||||
let contents = self.convert_items_to_contents(&request.items);
|
||||
|
||||
// System prompt
|
||||
let system_instruction = request.system_prompt.as_ref().map(|s| GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: vec![GeminiPart::Text { text: s.clone() }],
|
||||
});
|
||||
|
||||
// Tools
|
||||
let tools = if request.tools.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![GeminiTool {
|
||||
function_declarations: request.tools.iter().map(|t| self.convert_tool(t)).collect(),
|
||||
}]
|
||||
};
|
||||
|
||||
// Tool config
|
||||
let tool_config = if !request.tools.is_empty() {
|
||||
Some(GeminiToolConfig {
|
||||
function_calling_config: GeminiFunctionCallingConfig {
|
||||
mode: Some("AUTO".to_string()),
|
||||
stream_function_call_arguments: if self.stream_function_call_arguments {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Reasoning の投影: capability が BudgetTokens / Both をサポートし、
|
||||
// request 側で budget_tokens が指定されているときだけ thinking_config を付ける。
|
||||
let supports_budget = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
|
||||
);
|
||||
let thinking_config = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_budget)
|
||||
.and_then(|rc| match rc {
|
||||
ReasoningControl::BudgetTokens(budget) => Some(GeminiThinkingConfig {
|
||||
thinking_budget: *budget,
|
||||
}),
|
||||
ReasoningControl::Effort(_) => None,
|
||||
});
|
||||
|
||||
// Generation config
|
||||
let generation_config = Some(GeminiGenerationConfig {
|
||||
max_output_tokens: request.config.max_tokens,
|
||||
temperature: request.config.temperature,
|
||||
top_p: request.config.top_p,
|
||||
top_k: request.config.top_k,
|
||||
stop_sequences: request.config.stop_sequences.clone(),
|
||||
thinking_config,
|
||||
});
|
||||
|
||||
GeminiRequest {
|
||||
contents,
|
||||
system_instruction,
|
||||
tools,
|
||||
tool_config,
|
||||
generation_config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Open Responses Items to Gemini Contents
|
||||
///
|
||||
/// Gemini uses:
|
||||
/// - role "user" for user messages and function responses
|
||||
/// - role "model" for assistant messages and function calls
|
||||
fn convert_items_to_contents(&self, items: &[Item]) -> Vec<GeminiContent> {
|
||||
let mut contents = Vec::new();
|
||||
let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending parts
|
||||
self.flush_pending_parts(
|
||||
&mut contents,
|
||||
&mut pending_model_parts,
|
||||
&mut pending_user_parts,
|
||||
);
|
||||
|
||||
let gemini_role = match role {
|
||||
Role::User | Role::System => "user",
|
||||
Role::Assistant => "model",
|
||||
};
|
||||
|
||||
let parts: Vec<GeminiPart> = content
|
||||
.iter()
|
||||
.map(|p| GeminiPart::Text {
|
||||
text: p.as_text().to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
contents.push(GeminiContent {
|
||||
role: gemini_role.to_string(),
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
} => {
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(&mut pending_user_parts),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse arguments (normalize non-object / legacy "null" payloads to {})
|
||||
let args = parse_tool_arguments(arguments);
|
||||
|
||||
pending_model_parts.push(GeminiPart::FunctionCall {
|
||||
function_call: GeminiFunctionCall {
|
||||
name: name.clone(),
|
||||
args,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
// Flush pending model parts first
|
||||
if !pending_model_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "model".to_string(),
|
||||
parts: std::mem::take(&mut pending_model_parts),
|
||||
});
|
||||
}
|
||||
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
pending_user_parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponse {
|
||||
name: call_id.clone(),
|
||||
response: GeminiFunctionResponseContent {
|
||||
name: call_id.clone(),
|
||||
content: Value::String(text),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::Reasoning { text, .. } => {
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(&mut pending_user_parts),
|
||||
});
|
||||
}
|
||||
|
||||
// Reasoning is treated as model text in Gemini
|
||||
pending_model_parts.push(GeminiPart::Text { text: text.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending parts
|
||||
self.flush_pending_parts(
|
||||
&mut contents,
|
||||
&mut pending_model_parts,
|
||||
&mut pending_user_parts,
|
||||
);
|
||||
|
||||
contents
|
||||
}
|
||||
|
||||
fn flush_pending_parts(
|
||||
&self,
|
||||
contents: &mut Vec<GeminiContent>,
|
||||
pending_model_parts: &mut Vec<GeminiPart>,
|
||||
pending_user_parts: &mut Vec<GeminiPart>,
|
||||
) {
|
||||
if !pending_model_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "model".to_string(),
|
||||
parts: std::mem::take(pending_model_parts),
|
||||
});
|
||||
}
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(pending_user_parts),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tool(&self, tool: &ToolDefinition) -> GeminiFunctionDeclaration {
|
||||
GeminiFunctionDeclaration {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_budget_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::BudgetTokens),
|
||||
..cap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_simple_request() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.system("You are a helpful assistant.")
|
||||
.user("Hello!");
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert!(gemini_req.system_instruction.is_some());
|
||||
assert_eq!(gemini_req.contents.len(), 1);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_with_tool() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new().user("What's the weather?").tool(
|
||||
ToolDefinition::new("get_weather")
|
||||
.description("Get current weather")
|
||||
.input_schema(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": { "type": "string" }
|
||||
},
|
||||
"required": ["location"]
|
||||
})),
|
||||
);
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.tools.len(), 1);
|
||||
assert_eq!(gemini_req.tools[0].function_declarations.len(), 1);
|
||||
assert_eq!(
|
||||
gemini_req.tools[0].function_declarations[0].name,
|
||||
"get_weather"
|
||||
);
|
||||
assert!(gemini_req.tool_config.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_role_is_model() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new().user("Hello").assistant("Hi there!");
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.contents.len(), 2);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_and_result() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.user("What's the weather?")
|
||||
.item(Item::tool_call(
|
||||
"call_123",
|
||||
"get_weather",
|
||||
r#"{"city":"Tokyo"}"#,
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.contents.len(), 3);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
assert_eq!(gemini_req.contents[2].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_budget_projected_when_supported() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let mut request = Request::new().user("think");
|
||||
request.config.reasoning = Some(ReasoningControl::BudgetTokens(-1));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap_budget_reasoning());
|
||||
let config = gemini_req.generation_config.expect("generation config");
|
||||
let thinking = config.thinking_config.expect("thinking config");
|
||||
|
||||
assert_eq!(thinking.thinking_budget, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_reasoning_not_projected_to_gemini() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let mut request = Request::new().user("think");
|
||||
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Medium));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap_budget_reasoning());
|
||||
let config = gemini_req.generation_config.expect("generation config");
|
||||
|
||||
assert!(config.thinking_config.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! `impl Scheme for GeminiScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme,
|
||||
types::Request,
|
||||
};
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
impl Scheme for GeminiScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
}
|
||||
|
||||
fn path(&self, model_id: &str) -> String {
|
||||
format!("/v1beta/models/{model_id}:streamGenerateContent?alt=sse")
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::QueryParam { name: "key" }
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
_model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(request, capability);
|
||||
serde_json::to_value(&req).expect("GeminiRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! APIスキーマ定義
|
||||
//!
|
||||
//! 各APIスキーマごとの変換ロジック
|
||||
//! - リクエスト変換: Request → プロバイダ固有JSON
|
||||
//! - レスポンス変換: SSEイベント → Event
|
||||
//!
|
||||
//! [`Scheme`] trait により `HttpTransport<S>` から scheme 固有の差分
|
||||
//! (パス、ヘッダ、認証要件、body 生成、SSE パース)をすべて委譲する。
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod gemini;
|
||||
pub mod openai_chat;
|
||||
pub mod openai_responses;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::auth::AuthRequirement;
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::ConfigWarning;
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::types::{Request, RequestConfig};
|
||||
|
||||
/// wire scheme の抽象。各プロバイダの API 仕様ごとに 1 つ実装する。
|
||||
///
|
||||
/// `HttpTransport<S: Scheme>` が URL 組立・認証ヘッダ挿入・SSE パース
|
||||
/// のループを担い、`Scheme` 実装は各仕様固有の差分のみ提供する。
|
||||
///
|
||||
/// # 状態
|
||||
///
|
||||
/// SSE パースでフレーム間に状態を保つ必要がある scheme(Anthropic の
|
||||
/// `BlockStop` に `block_type` が載らない仕様の補完など)は
|
||||
/// [`Scheme::State`] に中間状態を表す型を置く。
|
||||
/// 状態を持たない scheme は `type State = ()` とする。
|
||||
pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
/// SSE パースのフレーム間で共有する状態。`HttpTransport` が
|
||||
/// ストリーム開始時に `Default::default()` を一度だけ作り、
|
||||
/// フレームごとに `&mut` で渡す。
|
||||
type State: Default + Send + 'static;
|
||||
|
||||
/// scheme のベース URL(`ModelConfig::base_url` 未指定時のデフォルト)
|
||||
fn default_base_url(&self) -> &'static str;
|
||||
|
||||
/// リクエスト先の相対パス。Gemini のようにモデル名をパスに埋め込む
|
||||
/// プロバイダもあるため、モデル ID を受け取る。
|
||||
fn path(&self, model_id: &str) -> String;
|
||||
|
||||
/// この scheme が要求する認証形式。呼び出し側は client 構築時に
|
||||
/// 設定された認証情報と照合する。
|
||||
fn required_auth(&self) -> AuthRequirement;
|
||||
|
||||
/// `Content-Type` 以外の追加ヘッダ。`anthropic-version` / `anthropic-beta` 等。
|
||||
fn additional_headers(&self) -> Vec<(&'static str, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// リクエスト body を生成する。`capability` は `CacheStrategy` や
|
||||
/// `ReasoningSupport` を参照して scheme 側の挙動を分岐させるため
|
||||
/// に渡される。
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value;
|
||||
|
||||
/// SSE イベント 1 件を 0 個以上の [`Event`] に変換する。
|
||||
///
|
||||
/// `event_type` は SSE フレームの `event:` フィールド、`data` は
|
||||
/// `data:` フィールド。`[DONE]` 等の終端マーカーは実装側で判定する。
|
||||
/// `state` はストリーム単位で共有される可変状態。
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError>;
|
||||
|
||||
/// scheme 既定の capability。モデル ID に関係なく、この wire で
|
||||
/// 安全に送れる最小共通項を返す。既知モデル ID の能力テーブルは
|
||||
/// 高レベルの client 構築層の責務で、scheme はここには関与しない。
|
||||
fn default_capability(&self) -> ModelCapability;
|
||||
|
||||
/// scheme 側でサポートしていない `RequestConfig` フィールドを
|
||||
/// 警告として返す(例: OpenAI Chat は `top_k` 非対応)。
|
||||
/// デフォルトは空 Vec。
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let _ = config;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! OpenAI Chat Completions scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gpt-5` 系など)は client construction layer
|
||||
//! の責務。ここでは wire の保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等)
|
||||
/// で未知モデル ID を受けたときのフォールバックに使う。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! OpenAI SSEイベントパース
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{Event, StopReason, UsageEvent},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
/// OpenAI Streaming Chat Response Chunk
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChatCompletionChunk {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<ChunkChoice>,
|
||||
pub usage: Option<ChunkUsage>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkChoice {
|
||||
pub index: usize,
|
||||
pub delta: ChunkDelta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkDelta {
|
||||
pub role: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<ChunkToolCall>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkToolCall {
|
||||
pub index: usize,
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: Option<String>,
|
||||
pub function: Option<ChunkFunction>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkFunction {
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkUsage {
|
||||
pub prompt_tokens: u64,
|
||||
pub completion_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// SSEデータのパースとEventへの変換
|
||||
///
|
||||
/// OpenAI APIはBlockStartイベントを明示的に送信しない。
|
||||
/// Timeline層が暗黙的なBlockStartを処理する。
|
||||
pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
if data == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let chunk: ChatCompletionChunk =
|
||||
serde_json::from_str(data).map_err(|e| ClientError::Api {
|
||||
status: None,
|
||||
code: Some("parse_error".to_string()),
|
||||
message: format!("Failed to parse SSE data: {} -> {}", e, data),
|
||||
retry_after: None,
|
||||
})?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Usage handling
|
||||
if let Some(usage) = chunk.usage {
|
||||
events.push(Event::Usage(UsageEvent {
|
||||
input_tokens: Some(usage.prompt_tokens),
|
||||
output_tokens: Some(usage.completion_tokens),
|
||||
total_tokens: Some(usage.total_tokens),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
}));
|
||||
}
|
||||
|
||||
for choice in chunk.choices {
|
||||
// Text Content Delta
|
||||
if let Some(content) = choice.delta.content {
|
||||
// OpenAI APIはBlockStartを送らないため、デルタのみを発行
|
||||
// Timeline層が暗黙的なBlockStartを処理する
|
||||
events.push(Event::text_delta(choice.index, content));
|
||||
}
|
||||
|
||||
// Tool Call Delta
|
||||
if let Some(tool_calls) = choice.delta.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
// Start of tool call (has ID)
|
||||
if let Some(id) = tool_call.id {
|
||||
let name = tool_call
|
||||
.function
|
||||
.as_ref()
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_default();
|
||||
events.push(Event::tool_use_start(tool_call.index, id, name));
|
||||
}
|
||||
|
||||
// Arguments delta
|
||||
if let Some(function) = tool_call.function {
|
||||
if let Some(args) = function.arguments {
|
||||
if !args.is_empty() {
|
||||
events.push(Event::tool_input_delta(tool_call.index, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish Reason
|
||||
if let Some(finish_reason) = choice.finish_reason {
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"stop" => Some(StopReason::EndTurn),
|
||||
"length" => Some(StopReason::MaxTokens),
|
||||
"tool_calls" | "function_call" => Some(StopReason::ToolUse),
|
||||
_ => Some(StopReason::EndTurn),
|
||||
};
|
||||
|
||||
let is_tool_finish =
|
||||
finish_reason == "tool_calls" || finish_reason == "function_call";
|
||||
|
||||
if is_tool_finish {
|
||||
// ツール呼び出し終了
|
||||
// Note: OpenAIはどのツールが終了したか明示しないため、
|
||||
// Timeline層で適切に処理する必要がある
|
||||
} else {
|
||||
// テキスト終了
|
||||
events.push(Event::text_block_stop(choice.index, stop_reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::event::DeltaContent;
|
||||
|
||||
#[test]
|
||||
fn test_parse_text_delta() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
// OpenAIはBlockStartを発行しないため、デルタのみ
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
assert_eq!(delta.index, 0);
|
||||
if let DeltaContent::Text(text) = &delta.delta {
|
||||
assert_eq!(text, "Hello");
|
||||
} else {
|
||||
panic!("Expected text delta");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected BlockDelta");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_call() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
// Start of tool call
|
||||
let data_start = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data_start).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
if let Event::BlockStart(start) = &events[0] {
|
||||
assert_eq!(start.index, 0);
|
||||
if let crate::llm_client::event::BlockMetadata::ToolUse { id, name } = &start.metadata {
|
||||
assert_eq!(id, "call_abc");
|
||||
assert_eq!(name, "get_weather");
|
||||
} else {
|
||||
panic!("Expected ToolUse metadata");
|
||||
}
|
||||
}
|
||||
|
||||
// Tool arguments delta
|
||||
let data_arg = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}}"}}]},"finish_reason":null}]}"#;
|
||||
let events = scheme.parse_event(data_arg).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
if let DeltaContent::InputJson(json) = &delta.delta {
|
||||
assert_eq!(json, "{}}");
|
||||
} else {
|
||||
panic!("Expected input json delta");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! OpenAI Chat Completions API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
pub(crate) mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// OpenAIスキーマ
|
||||
///
|
||||
/// OpenAI Chat Completions API (および互換API) のリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OpenAIScheme {
|
||||
/// モデル名 (リクエスト時に指定されるが、デフォルト値として保持も可能)
|
||||
pub model: Option<String>,
|
||||
/// レガシーなmax_tokensを使用するか (Ollama互換用)
|
||||
pub use_legacy_max_tokens: bool,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// 新しいスキーマを作成
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// レガシーなmax_tokensを使用するか設定
|
||||
pub fn with_legacy_max_tokens(mut self, use_legacy: bool) -> Self {
|
||||
self.use_legacy_max_tokens = use_legacy;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
//! OpenAI Request Builder
|
||||
//!
|
||||
//! Converts Open Responses native Item model to OpenAI Chat Completions API format.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
|
||||
},
|
||||
tool::Attachment,
|
||||
};
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
/// OpenAI API request body
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIRequest {
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_completion_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>, // Legacy field for compatibility (e.g. Ollama)
|
||||
#[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: Vec<String>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
pub messages: Vec<OpenAIMessage>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<OpenAITool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<String>,
|
||||
/// Reasoning effort(o1 / o3 / o4 / gpt-5 系で有効)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct StreamOptions {
|
||||
pub include_usage: bool,
|
||||
}
|
||||
|
||||
/// OpenAI message
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIMessage {
|
||||
pub role: String,
|
||||
pub content: Option<OpenAIContent>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tool_calls: Vec<OpenAIToolCall>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI content
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum OpenAIContent {
|
||||
Text(String),
|
||||
Parts(Vec<OpenAIContentPart>),
|
||||
}
|
||||
|
||||
/// OpenAI content part
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum OpenAIContentPart {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image_url")]
|
||||
ImageUrl { image_url: ImageUrl },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ImageUrl {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// OpenAI tool definition
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAITool {
|
||||
pub r#type: String,
|
||||
pub function: OpenAIToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolFunction {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// OpenAI tool call in message
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolCall {
|
||||
pub id: String,
|
||||
pub r#type: String,
|
||||
pub function: OpenAIToolCallFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// Build OpenAI request from Request
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> OpenAIRequest {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// Add system message if present
|
||||
if let Some(system) = &request.system_prompt {
|
||||
messages.push(OpenAIMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OpenAIContent::Text(system.clone())),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert items to messages
|
||||
messages.extend(self.convert_items_to_messages(&request.items, capability.vision));
|
||||
|
||||
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
|
||||
|
||||
let (max_tokens, max_completion_tokens) = if self.use_legacy_max_tokens {
|
||||
(request.config.max_tokens, None)
|
||||
} else {
|
||||
(None, request.config.max_tokens)
|
||||
};
|
||||
|
||||
// Reasoning の投影: capability が Effort / Both をサポートし、
|
||||
// request 側で effort が指定されているときだけ reasoning_effort を付ける。
|
||||
let supports_effort = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
|
||||
);
|
||||
let reasoning_effort = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_effort)
|
||||
.and_then(|rc| match rc {
|
||||
ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
|
||||
ReasoningControl::BudgetTokens(_) => None,
|
||||
});
|
||||
|
||||
OpenAIRequest {
|
||||
model: model.to_string(),
|
||||
max_completion_tokens,
|
||||
max_tokens,
|
||||
temperature: request.config.temperature,
|
||||
top_p: request.config.top_p,
|
||||
stop: request.config.stop_sequences.clone(),
|
||||
stream: true,
|
||||
stream_options: Some(StreamOptions {
|
||||
include_usage: true,
|
||||
}),
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: None,
|
||||
reasoning_effort,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Open Responses Items to OpenAI Messages
|
||||
///
|
||||
/// OpenAI uses a message-based model where:
|
||||
/// - User messages have role "user"
|
||||
/// - Assistant messages have role "assistant"
|
||||
/// - Tool calls are within assistant messages as tool_calls array
|
||||
/// - Tool results have role "tool" with tool_call_id
|
||||
fn flush_pending_tool_result_images(
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
pending_images: &mut Vec<OpenAIContentPart>,
|
||||
) {
|
||||
if !pending_images.is_empty() {
|
||||
messages.push(OpenAIMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(OpenAIContent::Parts(std::mem::take(pending_images))),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_items_to_messages(
|
||||
&self,
|
||||
items: &[Item],
|
||||
supports_images: bool,
|
||||
) -> Vec<OpenAIMessage> {
|
||||
let mut messages = Vec::new();
|
||||
let mut pending_tool_calls: Vec<OpenAIToolCall> = Vec::new();
|
||||
let mut pending_assistant_text: Option<String> = None;
|
||||
let mut pending_tool_result_images: Vec<OpenAIContentPart> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
if !matches!(item, Item::ToolResult { .. }) {
|
||||
Self::flush_pending_tool_result_images(
|
||||
&mut messages,
|
||||
&mut pending_tool_result_images,
|
||||
);
|
||||
}
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending tool calls
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
|
||||
let openai_role = match role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
};
|
||||
let message_content = OpenAIContent::Text(
|
||||
content
|
||||
.iter()
|
||||
.map(ContentPart::as_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
);
|
||||
|
||||
messages.push(OpenAIMessage {
|
||||
role: openai_role.to_string(),
|
||||
content: Some(message_content),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
// Normalize non-object / legacy "null" payloads to "{}" so
|
||||
// OpenAI gets a valid JSON object string.
|
||||
let normalized_args = parse_tool_arguments(arguments).to_string();
|
||||
pending_tool_calls.push(OpenAIToolCall {
|
||||
id: call_id.clone(),
|
||||
r#type: "function".to_string(),
|
||||
function: OpenAIToolCallFunction {
|
||||
name: name.clone(),
|
||||
arguments: normalized_args,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
..
|
||||
} => {
|
||||
// OpenAI requires every parallel tool result before a new user message.
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
|
||||
let mut text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
if supports_images {
|
||||
pending_tool_result_images.extend(attachments.iter().map(|attachment| {
|
||||
let Attachment::Image(image) = attachment;
|
||||
OpenAIContentPart::ImageUrl {
|
||||
image_url: ImageUrl {
|
||||
url: image_data_url(image.mime_type(), image.data()),
|
||||
},
|
||||
}
|
||||
}));
|
||||
} else if !attachments.is_empty() {
|
||||
text.push_str(&format!(
|
||||
"\n[{} image attachment(s) omitted: model does not support images]",
|
||||
attachments.len()
|
||||
));
|
||||
}
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(text)),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: Some(call_id.clone()),
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
Item::Reasoning { text, .. } => {
|
||||
// Reasoning is treated as assistant text in OpenAI
|
||||
// (OpenAI doesn't have native reasoning support like Claude)
|
||||
if let Some(ref mut existing) = pending_assistant_text {
|
||||
existing.push_str(text);
|
||||
} else {
|
||||
pending_assistant_text = Some(text.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending items
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
Self::flush_pending_tool_result_images(&mut messages, &mut pending_tool_result_images);
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
fn flush_pending_assistant(
|
||||
&self,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
pending_tool_calls: &mut Vec<OpenAIToolCall>,
|
||||
pending_assistant_text: &mut Option<String>,
|
||||
) {
|
||||
if !pending_tool_calls.is_empty() || pending_assistant_text.is_some() {
|
||||
messages.push(OpenAIMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: pending_assistant_text.take().map(OpenAIContent::Text),
|
||||
tool_calls: std::mem::take(pending_tool_calls),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tool(&self, tool: &ToolDefinition) -> OpenAITool {
|
||||
OpenAITool {
|
||||
r#type: "function".to_string(),
|
||||
function: OpenAIToolFunction {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn vision_cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
vision: true,
|
||||
..cap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_simple_request() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new().system("System prompt").user("Hello");
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.model, "gpt-4o");
|
||||
assert_eq!(body.messages.len(), 2);
|
||||
assert_eq!(body.messages[0].role, "system");
|
||||
assert_eq!(body.messages[1].role, "user");
|
||||
|
||||
if let Some(OpenAIContent::Text(text)) = &body.messages[0].content {
|
||||
assert_eq!(text, "System prompt");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_with_tool() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new()
|
||||
.user("Check weather")
|
||||
.tool(ToolDefinition::new("weather").description("Get weather"));
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
assert_eq!(body.tools.len(), 1);
|
||||
assert_eq!(body.tools[0].function.name, "weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_legacy_max_tokens() {
|
||||
let scheme = OpenAIScheme::new().with_legacy_max_tokens(true);
|
||||
let request = Request::new().user("Hello").max_tokens(100);
|
||||
|
||||
let body = scheme.build_request("llama3", &request, &cap());
|
||||
|
||||
assert_eq!(body.max_tokens, Some(100));
|
||||
assert!(body.max_completion_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_modern_max_tokens() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new().user("Hello").max_tokens(100);
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.max_completion_tokens, Some(100));
|
||||
assert!(body.max_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_projected_when_supported() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let mut request = Request::new().user("Hello");
|
||||
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Other(
|
||||
"provider-native".into(),
|
||||
)));
|
||||
let capability = ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::Effort),
|
||||
..cap()
|
||||
};
|
||||
|
||||
let body = scheme.build_request("gpt-5", &request, &capability);
|
||||
|
||||
assert_eq!(body.reasoning_effort.as_deref(), Some("provider-native"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_reasoning_not_projected_to_openai_chat() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let mut request = Request::new().user("Hello");
|
||||
request.config.reasoning = Some(ReasoningControl::BudgetTokens(4096));
|
||||
let capability = ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::Both),
|
||||
..cap()
|
||||
};
|
||||
|
||||
let body = scheme.build_request("gpt-5", &request, &capability);
|
||||
|
||||
assert!(body.reasoning_effort.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_and_result() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new()
|
||||
.user("Check weather")
|
||||
.item(Item::tool_call(
|
||||
"call_123",
|
||||
"get_weather",
|
||||
r#"{"city":"Tokyo"}"#,
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.messages.len(), 3);
|
||||
assert_eq!(body.messages[0].role, "user");
|
||||
assert_eq!(body.messages[1].role, "assistant");
|
||||
assert_eq!(body.messages[1].tool_calls.len(), 1);
|
||||
assert_eq!(body.messages[2].role, "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_tool_results_precede_durable_image_projection() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||
let request = Request::new()
|
||||
.item(Item::tool_call("call_image", "ViewImage", "{}"))
|
||||
.item(Item::tool_call("call_text", "Read", "{}"))
|
||||
.item(Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"Attached image",
|
||||
None,
|
||||
false,
|
||||
vec![crate::tool::Attachment::Image(
|
||||
crate::tool::ImageAttachment::new("image/png", image),
|
||||
)],
|
||||
))
|
||||
.item(Item::tool_result_item(
|
||||
"call_text",
|
||||
"Read text",
|
||||
None,
|
||||
false,
|
||||
));
|
||||
let json = serde_json::to_value(
|
||||
&scheme
|
||||
.build_request("gpt-4o", &request, &vision_cap())
|
||||
.messages,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(json[0]["role"], "assistant");
|
||||
assert_eq!(json[1]["role"], "tool");
|
||||
assert_eq!(json[2]["role"], "tool");
|
||||
assert_eq!(json[3]["role"], "user");
|
||||
assert_eq!(json[3]["content"][0]["type"], "image_url");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_tool_image_is_deterministically_lowered_to_following_user_content() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||
let attachment = crate::tool::Attachment::Image(crate::tool::ImageAttachment::new(
|
||||
"image/png",
|
||||
image.clone(),
|
||||
));
|
||||
let item = Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"Attached image",
|
||||
None,
|
||||
false,
|
||||
vec![attachment],
|
||||
);
|
||||
let persisted = serde_json::to_string(&item).unwrap();
|
||||
assert!(persisted.contains("attachments"));
|
||||
let restored: Item = serde_json::from_str(&persisted).unwrap();
|
||||
|
||||
let request = Request::new()
|
||||
.item(Item::tool_call(
|
||||
"call_image",
|
||||
"ViewImage",
|
||||
r#"{"path":"a.png"}"#,
|
||||
))
|
||||
.item(restored);
|
||||
let body = scheme.build_request("gpt-4o", &request, &vision_cap());
|
||||
let json = serde_json::to_value(&body.messages).unwrap();
|
||||
let rebuilt = serde_json::to_value(
|
||||
&scheme
|
||||
.build_request("gpt-4o", &request, &vision_cap())
|
||||
.messages,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rebuilt, json);
|
||||
|
||||
assert_eq!(json[0]["role"], "assistant");
|
||||
assert_eq!(json[1]["role"], "tool");
|
||||
assert_eq!(json[2]["role"], "user");
|
||||
assert_eq!(json[2]["content"][0]["type"], "image_url");
|
||||
assert!(
|
||||
json[2]["content"][0]["image_url"]["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
|
||||
let mut no_vision = cap();
|
||||
no_vision.vision = false;
|
||||
let disabled =
|
||||
serde_json::to_string(&scheme.build_request("gpt-4o", &request, &no_vision)).unwrap();
|
||||
assert!(!disabled.contains("data:image"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! `impl Scheme for OpenAIScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ConfigWarning,
|
||||
event::Event,
|
||||
scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
};
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
impl Scheme for OpenAIScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.openai.com"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/v1/chat/completions".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&req).expect("OpenAIRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
// `data: [DONE]` は終端マーカー
|
||||
if data.trim() == "[DONE]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// OpenAI Chat Completions API は top_k を受け付けない
|
||||
if config.top_k.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported("top_k", "OpenAI Chat"));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! OpenAI Responses scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有の能力テーブルは高レベルの client 構築層の責務。
|
||||
//! ここでは wire の保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
//! OpenAI Responses API スキーマ (`/v1/responses`)
|
||||
//!
|
||||
//! Chat Completions とは別物の item-based wire format。reasoning item と
|
||||
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
||||
//! 流れる。
|
||||
//!
|
||||
//! - リクエスト JSON 生成: `request`
|
||||
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: `events`
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::OpenAIResponsesState;
|
||||
|
||||
/// OpenAI Responses scheme 本体。
|
||||
///
|
||||
/// `store` / `include_encrypted_content` / `send_max_output_tokens` /
|
||||
/// `send_sampling_params` は scheme 固定の wire 設定で、デフォルトは
|
||||
/// 公式 OpenAI Responses API 向け (stateless + ZDR + `max_output_tokens`
|
||||
/// / `temperature` / `top_p` 送出可)。受理パラメータが subset の
|
||||
/// 互換 backend では client 構築層で `send_max_output_tokens=false` /
|
||||
/// `send_sampling_params=false` に上書きする。`ModelCapability` には
|
||||
/// 入れない(モデル能力ではなく wire policy)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAIResponsesScheme {
|
||||
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
|
||||
pub store: bool,
|
||||
/// `include: ["reasoning.encrypted_content"]` を付けるか。
|
||||
/// `store=false` で reasoning を使うなら必須。
|
||||
pub include_encrypted_content: bool,
|
||||
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
|
||||
/// 受理するが、互換 backend によっては `Unsupported parameter` で
|
||||
/// 400 を返すため、その経路では `false` にする。
|
||||
pub send_max_output_tokens: bool,
|
||||
/// `temperature` / `top_p` を body に載せるか。公式 OpenAI Responses API
|
||||
/// は受理するが、互換 backend によっては `Unsupported parameter` で
|
||||
/// 400 を返すため、その経路では `false` にする。
|
||||
pub send_sampling_params: bool,
|
||||
}
|
||||
|
||||
impl Default for OpenAIResponsesScheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
store: false,
|
||||
include_encrypted_content: true,
|
||||
send_max_output_tokens: true,
|
||||
send_sampling_params: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
|
||||
/// `send_max_output_tokens=true`, `send_sampling_params=true`)。
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `store` を上書き。
|
||||
pub fn with_store(mut self, store: bool) -> Self {
|
||||
self.store = store;
|
||||
self
|
||||
}
|
||||
|
||||
/// `include: ["reasoning.encrypted_content"]` の有無を上書き。
|
||||
pub fn with_include_encrypted_content(mut self, include: bool) -> Self {
|
||||
self.include_encrypted_content = include;
|
||||
self
|
||||
}
|
||||
|
||||
/// `max_output_tokens` を body に載せるかを上書き。
|
||||
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
|
||||
self.send_max_output_tokens = send;
|
||||
self
|
||||
}
|
||||
|
||||
/// `temperature` / `top_p` を body に載せるかを上書き。
|
||||
pub fn with_send_sampling_params(mut self, send: bool) -> Self {
|
||||
self.send_sampling_params = send;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
//! OpenAI Responses API リクエスト body 生成
|
||||
//!
|
||||
//! Chat Completions の `messages` と違い、Responses は `input[]` の
|
||||
//! item 配列で reasoning / function_call / function_call_output が
|
||||
//! first-class。`Item` を素に近い形で `input[]` に投影できる。
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
|
||||
},
|
||||
tool::Attachment,
|
||||
};
|
||||
|
||||
use super::OpenAIResponsesScheme;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum FunctionCallOutputBody {
|
||||
Text(String),
|
||||
ContentItems(Vec<FunctionCallOutputContentItem>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum FunctionCallOutputContentItem {
|
||||
InputText { text: String },
|
||||
InputImage { image_url: String },
|
||||
}
|
||||
|
||||
/// `/v1/responses` のリクエスト body。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponsesRequest {
|
||||
pub model: String,
|
||||
/// システムプロンプト相当。`input[]` とは別フィールド。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
pub input: Vec<InputItem>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<ResponseTool>,
|
||||
/// 常時 `"auto"` を送る。scheme 固定値。
|
||||
pub tool_choice: &'static str,
|
||||
/// 常時 `true` を送る。scheme 固定値。
|
||||
pub parallel_tool_calls: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<ReasoningConfig>,
|
||||
/// ZDR / stateless 運用では `false`。
|
||||
pub store: bool,
|
||||
/// 常時 `true`。
|
||||
pub stream: bool,
|
||||
/// `["reasoning.encrypted_content"]` 等。
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub include: Vec<&'static str>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
|
||||
/// 400 で弾く。scheme の `send_max_output_tokens` が `false` のときは
|
||||
/// `None` のまま送る (skip_serializing_if で除外)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
|
||||
/// `temperature` / `top_p` を 400 で弾く。scheme の
|
||||
/// `send_sampling_params` が `false` のときは `None` のまま送る。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// 会話単位の安定キー。明示キーを必要とする backend では、
|
||||
/// 呼び出し側が安定した conversation identifier を渡す。
|
||||
/// `Request::cache_key` が `None` のときはキー自体を送らない。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
/// reasoning 制御。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ReasoningConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
|
||||
pub summary: &'static str,
|
||||
}
|
||||
|
||||
/// `input[]` の 1 要素。
|
||||
///
|
||||
/// Responses API の item 型を素に近い形で投影する。未対応 type は
|
||||
/// 無視(reasoning 送信時に `content: []` の場合は `None` として弾く)。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum InputItem {
|
||||
/// 会話メッセージ。user / assistant / developer のいずれか。
|
||||
/// `Role::System` items は `developer` として投影する。OpenAI
|
||||
/// Responses 互換 backend の一部は `role: "system"` を拒否するため、
|
||||
/// system 相当の挿入には `role: "developer"` を使う。
|
||||
Message {
|
||||
role: &'static str,
|
||||
content: Vec<InputContent>,
|
||||
},
|
||||
/// 過去の function tool 呼び出し(assistant 側)。
|
||||
FunctionCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
/// JSON 文字列(object でなくても正規化済み)。
|
||||
arguments: String,
|
||||
},
|
||||
/// function tool の結果(user 側)。
|
||||
FunctionCallOutput {
|
||||
call_id: String,
|
||||
output: FunctionCallOutputBody,
|
||||
},
|
||||
/// reasoning item。`encrypted_content` があれば必ず添える。
|
||||
Reasoning {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
/// Responses API は reasoning item に `summary` フィールドを必須で
|
||||
/// 要求する(中身が空でも `[]` として送る必要がある)。GPT-5 など
|
||||
/// summary を返さないモデル + reasoning effort 指定なしのターンでは
|
||||
/// summary text が一切付かないので、ここを skip すると 400
|
||||
/// "Missing required parameter: 'input[N].summary'" で弾かれる。
|
||||
summary: Vec<ReasoningSummaryPart>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
content: Vec<ReasoningContentPart>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
encrypted_content: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// メッセージ content_part。role で input/output を使い分ける。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum InputContent {
|
||||
/// user / developer 側のテキスト
|
||||
InputText { text: String },
|
||||
/// user 側の画像
|
||||
/// assistant 側のテキスト
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ReasoningSummaryPart {
|
||||
SummaryText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ReasoningContentPart {
|
||||
ReasoningText { text: String },
|
||||
}
|
||||
|
||||
/// Responses 用 tool 定義。Chat と違い function キーでネストせず
|
||||
/// トップレベルに `name` / `parameters` が載る。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponseTool {
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: &'static str,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// OpenAI Responses API は `type:"object"` のパラメータスキーマに
|
||||
/// `properties` が存在することを要求する。schemars は引数なし struct
|
||||
/// から `properties` を含まない最小スキーマを出すので、serialize
|
||||
/// 時に空オブジェクトを補う。
|
||||
#[serde(serialize_with = "serialize_parameters")]
|
||||
pub parameters: Value,
|
||||
/// Structured output モード制御。デフォルト false。
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
fn serialize_parameters<S: Serializer>(value: &Value, s: S) -> Result<S::Ok, S::Error> {
|
||||
if let Some(obj) = value.as_object()
|
||||
&& obj.get("type").and_then(Value::as_str) == Some("object")
|
||||
&& !obj.contains_key("properties")
|
||||
{
|
||||
let mut patched = obj.clone();
|
||||
patched.insert("properties".to_string(), Value::Object(Default::default()));
|
||||
return Value::Object(patched).serialize(s);
|
||||
}
|
||||
value.serialize(s)
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// `Request` から wire 形式の body を組み立てる。
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> ResponsesRequest {
|
||||
let input = convert_items_to_input(&request.items, capability.vision);
|
||||
let tools = request.tools.iter().map(convert_tool).collect();
|
||||
|
||||
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
|
||||
// request 側で effort が指定されているときだけ reasoning を付ける。
|
||||
let supports_effort = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
|
||||
);
|
||||
let reasoning = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_effort)
|
||||
.map(|effort| ReasoningConfig {
|
||||
effort: match effort {
|
||||
ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
|
||||
ReasoningControl::BudgetTokens(_) => None,
|
||||
},
|
||||
summary: "auto",
|
||||
})
|
||||
.filter(|reasoning| reasoning.effort.is_some());
|
||||
|
||||
let include: Vec<&'static str> = if self.include_encrypted_content {
|
||||
vec!["reasoning.encrypted_content"]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
ResponsesRequest {
|
||||
model: model.to_string(),
|
||||
instructions: request.system_prompt.clone(),
|
||||
input,
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
reasoning,
|
||||
store: self.store,
|
||||
stream: true,
|
||||
include,
|
||||
max_output_tokens: if self.send_max_output_tokens {
|
||||
request.config.max_tokens
|
||||
} else {
|
||||
None
|
||||
},
|
||||
temperature: if self.send_sampling_params {
|
||||
request.config.temperature
|
||||
} else {
|
||||
None
|
||||
},
|
||||
top_p: if self.send_sampling_params {
|
||||
request.config.top_p
|
||||
} else {
|
||||
None
|
||||
},
|
||||
prompt_cache_key: request.cache_key.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Item` 列を `input[]` に変換する。
|
||||
fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputItem> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
|
||||
match role {
|
||||
Role::User => ("user", |t| InputContent::InputText { text: t }),
|
||||
Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }),
|
||||
Role::System => ("developer", |t| InputContent::InputText { text: t }),
|
||||
};
|
||||
let parts: Vec<InputContent> = content
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
ContentPart::Text { text } => text_variant(text.clone()),
|
||||
ContentPart::Refusal { refusal } => text_variant(refusal.clone()),
|
||||
})
|
||||
.collect();
|
||||
out.push(InputItem::Message {
|
||||
role: role_str,
|
||||
content: parts,
|
||||
});
|
||||
}
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
// 非 object / 旧形式の "null" を "{}" に正規化。
|
||||
let normalized = parse_tool_arguments(arguments).to_string();
|
||||
out.push(InputItem::FunctionCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: normalized,
|
||||
});
|
||||
}
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
..
|
||||
} => {
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
let output = if attachments.is_empty() {
|
||||
FunctionCallOutputBody::Text(text)
|
||||
} else if supports_images {
|
||||
let mut parts = vec![FunctionCallOutputContentItem::InputText { text }];
|
||||
parts.extend(attachments.iter().map(|attachment| {
|
||||
let Attachment::Image(image) = attachment;
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: image_data_url(image.mime_type(), image.data()),
|
||||
}
|
||||
}));
|
||||
FunctionCallOutputBody::ContentItems(parts)
|
||||
} else {
|
||||
FunctionCallOutputBody::Text(format!(
|
||||
"{text}\n[{} image attachment(s) omitted: model does not support images]",
|
||||
attachments.len()
|
||||
))
|
||||
};
|
||||
out.push(InputItem::FunctionCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output,
|
||||
});
|
||||
}
|
||||
Item::Reasoning {
|
||||
id,
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
..
|
||||
} => {
|
||||
let summary_parts = summary
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| ReasoningSummaryPart::SummaryText { text: s.clone() })
|
||||
.collect();
|
||||
let content_parts = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![ReasoningContentPart::ReasoningText { text: text.clone() }]
|
||||
};
|
||||
out.push(InputItem::Reasoning {
|
||||
id: id.clone(),
|
||||
summary: summary_parts,
|
||||
content: content_parts,
|
||||
encrypted_content: encrypted_content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn convert_tool(tool: &ToolDefinition) -> ResponseTool {
|
||||
ResponseTool {
|
||||
r#type: "function",
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
strict: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, ReasoningControl, ReasoningEffort, ReasoningSupport,
|
||||
StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap_with_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: Some(ReasoningSupport::Effort),
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_no_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
reasoning: None,
|
||||
..cap_with_reasoning()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_defaults_to_stateless_zdr() {
|
||||
let s = OpenAIResponsesScheme::new();
|
||||
assert!(!s.store);
|
||||
assert!(s.include_encrypted_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_encrypted_content_when_enabled() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.include, vec!["reasoning.encrypted_content"]);
|
||||
assert!(!body.store);
|
||||
assert!(body.stream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_from_system_prompt() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().system("be terse").user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.instructions.as_deref(), Some("be terse"));
|
||||
assert_eq!(body.input.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_choice_and_parallel_are_fixed() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.tool_choice, "auto");
|
||||
assert!(body.parallel_tool_calls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_uses_input_text() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[0] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "user");
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], InputContent::InputText { text } if text == "hi"));
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_role_item_is_projected_as_developer() {
|
||||
// Some compatible backends reject `role: "system"` in input[].
|
||||
// Project in-conversation system notes as `role: "developer"` so
|
||||
// both official and compatible backends can accept them.
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("hi")
|
||||
.item(Item::system_message("[notify] hello"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "developer");
|
||||
assert!(
|
||||
matches!(&content[0], InputContent::InputText { text } if text == "[notify] hello"),
|
||||
);
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_message_uses_output_text() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").assistant("hello");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "assistant");
|
||||
assert!(
|
||||
matches!(&content[0], InputContent::OutputText { text } if text == "hello")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_and_result_become_function_items() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("run")
|
||||
.item(Item::tool_call("c1", "t", r#"{"a":1}"#))
|
||||
.item(Item::tool_result("c1", "ok"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(matches!(body.input[1], InputItem::FunctionCall { .. }));
|
||||
assert!(matches!(
|
||||
body.input[2],
|
||||
InputItem::FunctionCallOutput { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_item_round_trips_encrypted_content() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let item = Item::reasoning("inner")
|
||||
.with_reasoning_summary(vec!["s1".into()])
|
||||
.with_encrypted_content("ENC");
|
||||
let req = Request::new().user("hi").item(item);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Reasoning {
|
||||
summary,
|
||||
content,
|
||||
encrypted_content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(summary.len(), 1);
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(encrypted_content.as_deref(), Some("ENC"));
|
||||
}
|
||||
_ => panic!("expected reasoning"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_reasoning_items_are_preserved_across_user_turns() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let old_reasoning = Item::reasoning("old").with_encrypted_content("OLD_ENC");
|
||||
let current_reasoning = Item::reasoning("current").with_encrypted_content("CURRENT_ENC");
|
||||
let req = Request::new()
|
||||
.user("old prompt")
|
||||
.item(old_reasoning)
|
||||
.assistant("old answer")
|
||||
.user("new prompt")
|
||||
.item(current_reasoning);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let encrypted: Vec<_> = body
|
||||
.input
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
InputItem::Reasoning {
|
||||
encrypted_content, ..
|
||||
} => encrypted_content.as_deref(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(encrypted, vec!["OLD_ENC", "CURRENT_ENC"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_is_kept_across_function_call_loop() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("run tool")
|
||||
.item(Item::reasoning("plan").with_encrypted_content("ENC"))
|
||||
.item(Item::tool_call("c1", "tool", "{}"))
|
||||
.item(Item::tool_result("c1", "ok"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(matches!(body.input[1], InputItem::Reasoning { .. }));
|
||||
assert!(matches!(body.input[2], InputItem::FunctionCall { .. }));
|
||||
assert!(matches!(
|
||||
body.input[3],
|
||||
InputItem::FunctionCallOutput { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_field_is_always_serialized() {
|
||||
// Responses API は reasoning item に `summary` を必須で要求する。
|
||||
// summary が空でも wire 上に `summary: []` として残らないと、
|
||||
// backend によっては missing required parameter として拒否される。
|
||||
// reasoning effort 未指定のターンでは summary text が付かないことが
|
||||
// あるため、空のままでも skip しないこと。
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let item = Item::reasoning("").with_encrypted_content("ENC");
|
||||
let req = Request::new().user("hi").item(item);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
let reasoning_item = &json["input"][1];
|
||||
assert_eq!(reasoning_item["type"], "reasoning");
|
||||
assert!(
|
||||
reasoning_item.get("summary").is_some(),
|
||||
"summary key must be present even when empty, got: {reasoning_item}"
|
||||
);
|
||||
assert_eq!(reasoning_item["summary"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_projected_when_supported() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut req = Request::new().user("hi");
|
||||
req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let reasoning = body.reasoning.expect("reasoning should be set");
|
||||
assert_eq!(reasoning.effort.as_deref(), Some("high"));
|
||||
assert_eq!(reasoning.summary, "auto");
|
||||
let json = serde_json::to_value(reasoning).unwrap();
|
||||
assert!(
|
||||
json.get("context").is_none(),
|
||||
"reasoning.context must not be serialized, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_omitted_when_unsupported() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut req = Request::new().user("hi");
|
||||
req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
|
||||
let body = scheme.build_request("gpt-4o", &req, &cap_no_reasoning());
|
||||
assert!(body.reasoning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_passed_through_by_default() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").max_tokens(100);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.max_output_tokens, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_dropped_when_send_disabled() {
|
||||
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(false);
|
||||
let req = Request::new().user("hi").max_tokens(100);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.max_output_tokens, None);
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("max_output_tokens").is_none(),
|
||||
"max_output_tokens key must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_params_passed_through_by_default() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").temperature(0.4).top_p(0.9);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.temperature, Some(0.4));
|
||||
assert_eq!(body.top_p, Some(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_params_dropped_when_send_disabled() {
|
||||
let scheme = OpenAIResponsesScheme::new().with_send_sampling_params(false);
|
||||
let req = Request::new().user("hi").temperature(0.4).top_p(0.9);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.temperature, None);
|
||||
assert_eq!(body.top_p, None);
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("temperature").is_none() && json.get("top_p").is_none(),
|
||||
"temperature/top_p keys must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_passed_through_when_set() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").cache_key("session-abc");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.prompt_cache_key.as_deref(), Some("session-abc"));
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["prompt_cache_key"], "session-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_omitted_when_none() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(body.prompt_cache_key.is_none());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("prompt_cache_key").is_none(),
|
||||
"prompt_cache_key key must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_without_properties_is_normalized() {
|
||||
// schemars は引数なし struct から `type:"object"` だけのスキーマを
|
||||
// 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので
|
||||
// 送る直前に空オブジェクトを補うのを確認。
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let raw_schema = serde_json::json!({ "type": "object" });
|
||||
let req = Request::new().tool(
|
||||
ToolDefinition::new("empty")
|
||||
.description("no args")
|
||||
.input_schema(raw_schema),
|
||||
);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["tools"][0]["parameters"]["type"], "object");
|
||||
assert!(
|
||||
json["tools"][0]["parameters"]["properties"].is_object(),
|
||||
"properties must be present as an object, got: {}",
|
||||
json["tools"][0]["parameters"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_with_properties_is_untouched() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let raw_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": { "path": { "type": "string" } },
|
||||
"required": ["path"]
|
||||
});
|
||||
let req = Request::new().tool(
|
||||
ToolDefinition::new("t")
|
||||
.description("d")
|
||||
.input_schema(raw_schema.clone()),
|
||||
);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["tools"][0]["parameters"], raw_schema);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_body_has_expected_shape() {
|
||||
// wire 形式が崩れていないかのスモークテスト
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.system("sys")
|
||||
.user("hi")
|
||||
.tool(ToolDefinition::new("t").description("d"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["model"], "gpt-5");
|
||||
assert_eq!(json["instructions"], "sys");
|
||||
assert_eq!(json["tool_choice"], "auto");
|
||||
assert_eq!(json["parallel_tool_calls"], true);
|
||||
assert_eq!(json["store"], false);
|
||||
assert_eq!(json["stream"], true);
|
||||
assert_eq!(json["include"][0], "reasoning.encrypted_content");
|
||||
assert_eq!(json["tools"][0]["type"], "function");
|
||||
assert_eq!(json["tools"][0]["name"], "t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_tool_image_uses_function_call_output_content_items() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||
let item = Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"Attached image",
|
||||
None,
|
||||
false,
|
||||
vec![crate::tool::Attachment::Image(
|
||||
crate::tool::ImageAttachment::new("image/png", image),
|
||||
)],
|
||||
);
|
||||
let persisted = serde_json::to_string(&item).unwrap();
|
||||
let restored: Item = serde_json::from_str(&persisted).unwrap();
|
||||
let req = Request::new()
|
||||
.item(Item::tool_call(
|
||||
"call_image",
|
||||
"ViewImage",
|
||||
r#"{"path":"a.png"}"#,
|
||||
))
|
||||
.item(restored);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
|
||||
assert_eq!(json["input"][1]["type"], "function_call_output");
|
||||
assert_eq!(json["input"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(json["input"][1]["output"][0]["type"], "input_text");
|
||||
assert_eq!(json["input"][1]["output"][1]["type"], "input_image");
|
||||
assert!(
|
||||
json["input"][1]["output"][1]["image_url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
let rebuilt =
|
||||
serde_json::to_value(scheme.build_request("gpt-5", &req, &cap_with_reasoning()))
|
||||
.unwrap();
|
||||
assert_eq!(rebuilt["input"], json["input"]);
|
||||
|
||||
let mut no_vision = cap_with_reasoning();
|
||||
no_vision.vision = false;
|
||||
let disabled =
|
||||
serde_json::to_string(&scheme.build_request("gpt-5", &req, &no_vision)).unwrap();
|
||||
assert!(!disabled.contains("data:image"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! `impl Scheme for OpenAIResponsesScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ConfigWarning,
|
||||
event::Event,
|
||||
scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
};
|
||||
|
||||
use super::OpenAIResponsesScheme;
|
||||
|
||||
pub use super::events::OpenAIResponsesState;
|
||||
|
||||
impl Scheme for OpenAIResponsesScheme {
|
||||
type State = OpenAIResponsesState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
// `/v1` は base_url 側に寄せる。互換 backend を使う場合も、
|
||||
// base URL を差し替えるだけで同じ `/responses` path を使える。
|
||||
"https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/responses".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let body = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&body).expect("ResponsesRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
super::events::parse_sse(event_type, data, state)
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// Some compatible backends reject `max_output_tokens` with HTTP 400.
|
||||
// If the scheme was built with `send_max_output_tokens=false`, body
|
||||
// projection is already disabled; only notify that the user's intent
|
||||
// was dropped.
|
||||
if !self.send_max_output_tokens && config.max_tokens.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"max_tokens",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
// Same for `temperature` / `top_p` on compatible backends that
|
||||
// reject unsupported sampling parameters.
|
||||
if !self.send_sampling_params {
|
||||
if config.temperature.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"temperature",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
if config.top_p.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"top_p",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
|
||||
//! HTTP クライアントで扱う。
|
||||
//!
|
||||
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲し、backend 固有の
|
||||
//! HTTP policy は [`TransportPolicy`] で明示的に差し込む。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use reqwest::header::{
|
||||
ACCEPT, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue,
|
||||
RETRY_AFTER, TRANSFER_ENCODING,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::auth::{AuthProvider, AuthRequirement};
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::{ConfigWarning, LlmClient, ResponseStream};
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::scheme::Scheme;
|
||||
use super::types::{Request, RequestConfig};
|
||||
|
||||
pub const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
pub const DEFAULT_FIRST_STREAM_EVENT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 認証設定をリクエスト時に使える形へ解決したランタイム表現。
|
||||
///
|
||||
/// - `None`: 認証ヘッダを送らない(Ollama 等の opt-out)
|
||||
/// - `ApiKey`: 静的な API key 文字列
|
||||
/// - `Custom`: リクエスト毎に動的にヘッダを組み立てる
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResolvedAuth {
|
||||
None,
|
||||
ApiKey(String),
|
||||
Custom(Arc<dyn AuthProvider>),
|
||||
}
|
||||
|
||||
impl ResolvedAuth {
|
||||
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
|
||||
///
|
||||
/// - `ResolvedAuth::None` は認証を付けない宣言なので、どの
|
||||
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
|
||||
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
|
||||
/// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」
|
||||
/// 宣言なので、scheme が要求する形式によらず受け入れる
|
||||
pub fn matches(&self, req: AuthRequirement) -> bool {
|
||||
match (self, req) {
|
||||
(Self::None, _) => true,
|
||||
(Self::Custom(_), _) => true,
|
||||
(
|
||||
Self::ApiKey(_),
|
||||
AuthRequirement::Bearer
|
||||
| AuthRequirement::XApiKey
|
||||
| AuthRequirement::QueryParam { .. },
|
||||
) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body encoding policy used by [`HttpTransport`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequestBodyEncoding {
|
||||
/// Send the request body as plain JSON.
|
||||
Json,
|
||||
/// Send the request body as zstd-compressed JSON with `Content-Encoding: zstd`.
|
||||
ZstdJson,
|
||||
}
|
||||
|
||||
impl Default for RequestBodyEncoding {
|
||||
fn default() -> Self {
|
||||
Self::Json
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversation header policy used by [`HttpTransport`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConversationHeaderPolicy {
|
||||
/// Do not derive any transport headers from [`Request::cache_key`].
|
||||
None,
|
||||
/// Send OpenAI-compatible conversation headers from [`Request::cache_key`].
|
||||
OpenAiCompatible {
|
||||
/// Send the legacy `session_id` header in addition to `session-id`.
|
||||
include_legacy_session_id: bool,
|
||||
/// Send `thread-id` with the same value as `session-id`.
|
||||
include_thread_id: bool,
|
||||
/// Send `x-client-request-id` with the same value as `session-id`.
|
||||
include_client_request_id: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ConversationHeaderPolicy {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-specific HTTP transport policy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TransportPolicy {
|
||||
/// How to serialize and encode the HTTP request body.
|
||||
pub request_body_encoding: RequestBodyEncoding,
|
||||
/// Optional backend-specific conversation headers derived from request metadata.
|
||||
pub conversation_headers: ConversationHeaderPolicy,
|
||||
}
|
||||
|
||||
impl TransportPolicy {
|
||||
/// Plain JSON requests with no derived conversation headers.
|
||||
pub fn standard() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// OpenAI-compatible backend profile that uses zstd JSON bodies and
|
||||
/// conversation headers derived from [`Request::cache_key`].
|
||||
pub fn openai_compatible_zstd() -> Self {
|
||||
Self {
|
||||
request_body_encoding: RequestBodyEncoding::ZstdJson,
|
||||
conversation_headers: ConversationHeaderPolicy::OpenAiCompatible {
|
||||
include_legacy_session_id: true,
|
||||
include_thread_id: true,
|
||||
include_client_request_id: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
fn header_value_for_diagnostics(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn response_header_diagnostics(headers: &HeaderMap) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"content_type": header_value_for_diagnostics(headers, CONTENT_TYPE.as_str()),
|
||||
"content_encoding": header_value_for_diagnostics(headers, CONTENT_ENCODING.as_str()),
|
||||
"transfer_encoding": header_value_for_diagnostics(headers, TRANSFER_ENCODING.as_str()),
|
||||
"content_length": header_value_for_diagnostics(headers, CONTENT_LENGTH.as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
fn request_header_diagnostics(headers: &HeaderMap) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"content_type": header_value_for_diagnostics(headers, CONTENT_TYPE.as_str()),
|
||||
"content_encoding": header_value_for_diagnostics(headers, CONTENT_ENCODING.as_str()),
|
||||
"accept": header_value_for_diagnostics(headers, ACCEPT.as_str()),
|
||||
"openai_beta": header_value_for_diagnostics(headers, "openai-beta"),
|
||||
"session_id_present": headers.contains_key("session-id"),
|
||||
"thread_id_present": headers.contains_key("thread-id"),
|
||||
"legacy_session_id_present": headers.contains_key("session_id"),
|
||||
"legacy_thread_id_present": headers.contains_key("thread_id"),
|
||||
"x_client_request_id_present": headers.contains_key("x-client-request-id"),
|
||||
"chatgpt_account_id_present": headers.contains_key("chatgpt-account-id"),
|
||||
})
|
||||
}
|
||||
|
||||
fn sse_error_context(status: u16, headers: &serde_json::Value, source: &str) -> String {
|
||||
let field = |name: &str| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("<none>")
|
||||
};
|
||||
format!(
|
||||
"SSE stream parse failed after HTTP {status}: {source}; content-type={}, content-encoding={}, transfer-encoding={}, content-length={}",
|
||||
field("content_type"),
|
||||
field("content_encoding"),
|
||||
field("transfer_encoding"),
|
||||
field("content_length")
|
||||
)
|
||||
}
|
||||
|
||||
/// scheme 共通の HTTP 通信層。
|
||||
pub struct HttpTransport<S: Scheme> {
|
||||
http_client: reqwest::Client,
|
||||
scheme: S,
|
||||
model_id: String,
|
||||
base_url: String,
|
||||
auth: ResolvedAuth,
|
||||
capability: ModelCapability,
|
||||
policy: TransportPolicy,
|
||||
}
|
||||
|
||||
impl<S: Scheme> HttpTransport<S> {
|
||||
/// 新しい transport を作る。`base_url` は末尾スラッシュの有無を
|
||||
/// どちらでも受け付ける(内部で正規化)。
|
||||
pub fn new(
|
||||
scheme: S,
|
||||
model_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
auth: ResolvedAuth,
|
||||
capability: ModelCapability,
|
||||
) -> Self {
|
||||
let base_url = base_url.into();
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
Self {
|
||||
http_client: reqwest::Client::new(),
|
||||
scheme,
|
||||
model_id: model_id.into(),
|
||||
base_url,
|
||||
auth,
|
||||
capability,
|
||||
policy: TransportPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a backend-specific HTTP transport policy.
|
||||
pub fn with_transport_policy(mut self, policy: TransportPolicy) -> Self {
|
||||
self.policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// カスタム HTTP クライアントを差し込む(テスト等)。
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
}
|
||||
|
||||
fn build_url(&self) -> String {
|
||||
let path = self.scheme.path(&self.model_id);
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
// Gemini のようにクエリパラメータで認証する場合は URL にキーを追記する
|
||||
if let (AuthRequirement::QueryParam { name }, ResolvedAuth::ApiKey(key)) =
|
||||
(self.scheme.required_auth(), &self.auth)
|
||||
{
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
format!("{url}{sep}{name}={key}")
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_headers(&self) -> Result<HeaderMap, ClientError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
match (&self.auth, self.scheme.required_auth()) {
|
||||
(ResolvedAuth::None, _) | (_, AuthRequirement::None) => {}
|
||||
(ResolvedAuth::Custom(provider), _) => {
|
||||
for (name, mut value) in provider.headers().await? {
|
||||
value.set_sensitive(true);
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
(ResolvedAuth::ApiKey(key), AuthRequirement::Bearer) => {
|
||||
let mut val = HeaderValue::from_str(&format!("Bearer {key}"))
|
||||
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
|
||||
val.set_sensitive(true);
|
||||
headers.insert("Authorization", val);
|
||||
}
|
||||
(ResolvedAuth::ApiKey(key), AuthRequirement::XApiKey) => {
|
||||
let mut val = HeaderValue::from_str(key.as_str())
|
||||
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
|
||||
val.set_sensitive(true);
|
||||
headers.insert("x-api-key", val);
|
||||
}
|
||||
(_, AuthRequirement::QueryParam { .. }) => {
|
||||
// クエリパラメータは `build_url` で付与済み
|
||||
}
|
||||
(ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => {
|
||||
// scheme が Custom を要求する組合せに ApiKey は流れてこない想定
|
||||
// (`matches()` で弾かれる)。安全側で何もしない
|
||||
}
|
||||
}
|
||||
|
||||
for (name, value) in self.scheme.additional_headers() {
|
||||
let hv = HeaderValue::from_str(&value)
|
||||
.map_err(|e| ClientError::Config(format!("invalid header {name}: {e}")))?;
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn apply_stream_headers(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
request: &Request,
|
||||
) -> Result<(), ClientError> {
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
|
||||
|
||||
if let ConversationHeaderPolicy::OpenAiCompatible {
|
||||
include_legacy_session_id,
|
||||
include_thread_id,
|
||||
include_client_request_id,
|
||||
} = self.policy.conversation_headers
|
||||
&& let Some(cache_key) = request.cache_key.as_deref()
|
||||
{
|
||||
let value = HeaderValue::from_str(cache_key).map_err(|e| {
|
||||
ClientError::Config(format!("invalid conversation header value: {e}"))
|
||||
})?;
|
||||
headers.insert(HeaderName::from_static("session-id"), value.clone());
|
||||
if include_thread_id {
|
||||
headers.insert(HeaderName::from_static("thread-id"), value.clone());
|
||||
}
|
||||
if include_legacy_session_id {
|
||||
headers.insert(HeaderName::from_static("session_id"), value.clone());
|
||||
}
|
||||
if include_client_request_id {
|
||||
headers.insert(HeaderName::from_static("x-client-request-id"), value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_request_body(
|
||||
&self,
|
||||
body: &serde_json::Value,
|
||||
headers: &mut HeaderMap,
|
||||
) -> Result<RequestBody, ClientError> {
|
||||
match self.policy.request_body_encoding {
|
||||
RequestBodyEncoding::Json => Ok(RequestBody::Json(body.clone())),
|
||||
RequestBodyEncoding::ZstdJson => {
|
||||
let raw = serde_json::to_vec(body)?;
|
||||
let raw_json_bytes = raw.len();
|
||||
let compressed =
|
||||
zstd::stream::encode_all(std::io::Cursor::new(raw), 3).map_err(|e| {
|
||||
ClientError::Config(format!("failed to zstd-compress request: {e}"))
|
||||
})?;
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
|
||||
Ok(RequestBody::CompressedJson {
|
||||
bytes: compressed,
|
||||
raw_json_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RequestBody {
|
||||
Json(serde_json::Value),
|
||||
CompressedJson {
|
||||
bytes: Vec<u8>,
|
||||
raw_json_bytes: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl RequestBody {
|
||||
fn encoding(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Json(_) => "json",
|
||||
Self::CompressedJson { .. } => "zstd",
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_json_bytes(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Json(body) => serde_json::to_vec(body).ok().map(|bytes| bytes.len()),
|
||||
Self::CompressedJson { raw_json_bytes, .. } => Some(*raw_json_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_bytes(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Json(body) => serde_json::to_vec(body).ok().map(|bytes| bytes.len()),
|
||||
Self::CompressedJson { bytes, .. } => Some(bytes.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_kind(auth: &ResolvedAuth) -> &'static str {
|
||||
match auth {
|
||||
ResolvedAuth::None => "none",
|
||||
ResolvedAuth::ApiKey(_) => "api_key",
|
||||
ResolvedAuth::Custom(_) => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_transport_trace(request: &Request, label: &str, data: Value) {
|
||||
if let Some(trace) = &request.transport_trace {
|
||||
trace.emit(label, data);
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_kind(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn request_body_shape_payload(body: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if let Some(input) = body.get("input").and_then(Value::as_array) {
|
||||
let items_json_bytes = serde_json::to_vec(input).map(|bytes| bytes.len()).ok();
|
||||
let mut reasoning_items = 0usize;
|
||||
let mut reasoning_encrypted_content_count = 0usize;
|
||||
let mut reasoning_encrypted_content_bytes = 0usize;
|
||||
for item in input {
|
||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
continue;
|
||||
}
|
||||
reasoning_items += 1;
|
||||
if let Some(encrypted) = item.get("encrypted_content").and_then(Value::as_str) {
|
||||
reasoning_encrypted_content_count += 1;
|
||||
reasoning_encrypted_content_bytes += encrypted.len();
|
||||
}
|
||||
}
|
||||
map.insert("items_len".to_string(), json!(input.len()));
|
||||
map.insert("items_json_bytes".to_string(), json!(items_json_bytes));
|
||||
map.insert("reasoning_items".to_string(), json!(reasoning_items));
|
||||
map.insert(
|
||||
"reasoning_encrypted_content_count".to_string(),
|
||||
json!(reasoning_encrypted_content_count),
|
||||
);
|
||||
map.insert(
|
||||
"reasoning_encrypted_content_bytes".to_string(),
|
||||
json!(reasoning_encrypted_content_bytes),
|
||||
);
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn api_error_code(error: &ClientError) -> Option<&str> {
|
||||
match error {
|
||||
ClientError::Api { code, .. } => code.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api { code, message, .. } => {
|
||||
code.as_deref() == Some("context_length_exceeded")
|
||||
|| message.contains("context_length_exceeded")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
phase: &'static str,
|
||||
) -> Result<reqwest::Response, ClientError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout { phase, timeout })?
|
||||
.map_err(ClientError::Http)
|
||||
}
|
||||
|
||||
impl<S: Scheme + Clone> Clone for HttpTransport<S> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
http_client: self.http_client.clone(),
|
||||
scheme: self.scheme.clone(),
|
||||
model_id: self.model_id.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
auth: self.auth.clone(),
|
||||
capability: self.capability.clone(),
|
||||
policy: self.policy.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// エラーレスポンスを `ClientError::Api` に変換する。
|
||||
async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||
let status = resp.status().as_u16();
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.map(Duration::from_secs);
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let error = json.get("error").unwrap_or(&json);
|
||||
let code = error
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| 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();
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
message,
|
||||
retry_after,
|
||||
}
|
||||
} else {
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code: None,
|
||||
message: text,
|
||||
retry_after,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
self.scheme.validate_config(config)
|
||||
}
|
||||
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let total_started = Instant::now();
|
||||
let path = self.scheme.path(&self.model_id);
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_start",
|
||||
json!({
|
||||
"model": &self.model_id,
|
||||
"path": path,
|
||||
"auth_kind": auth_kind(&self.auth),
|
||||
"required_auth": format!("{:?}", self.scheme.required_auth()),
|
||||
"request_body_encoding": format!("{:?}", self.policy.request_body_encoding),
|
||||
"conversation_headers": format!("{:?}", self.policy.conversation_headers),
|
||||
"cache_key_present": request.cache_key.is_some(),
|
||||
"stream_open_timeout_ms": DEFAULT_STREAM_OPEN_TIMEOUT.as_millis() as u64,
|
||||
}),
|
||||
);
|
||||
|
||||
let url = self.build_url();
|
||||
let headers_started = Instant::now();
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_headers_start",
|
||||
json!({
|
||||
"auth_kind": auth_kind(&self.auth),
|
||||
"required_auth": format!("{:?}", self.scheme.required_auth()),
|
||||
}),
|
||||
);
|
||||
let mut headers = match self.build_headers().await {
|
||||
Ok(headers) => {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_headers_done",
|
||||
json!({
|
||||
"elapsed_ms": headers_started.elapsed().as_millis() as u64,
|
||||
"headers_len": headers.len(),
|
||||
"headers": request_header_diagnostics(&headers),
|
||||
}),
|
||||
);
|
||||
headers
|
||||
}
|
||||
Err(error) => {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_headers_error",
|
||||
json!({
|
||||
"elapsed_ms": headers_started.elapsed().as_millis() as u64,
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let stream_headers_started = Instant::now();
|
||||
if let Err(error) = self.apply_stream_headers(&mut headers, &request) {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_stream_headers_error",
|
||||
json!({
|
||||
"elapsed_ms": stream_headers_started.elapsed().as_millis() as u64,
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_stream_headers_done",
|
||||
json!({
|
||||
"elapsed_ms": stream_headers_started.elapsed().as_millis() as u64,
|
||||
"headers_len": headers.len(),
|
||||
"headers": request_header_diagnostics(&headers),
|
||||
}),
|
||||
);
|
||||
|
||||
let body_started = Instant::now();
|
||||
emit_transport_trace(&request, "transport_body_build_start", json!({}));
|
||||
let body = self
|
||||
.scheme
|
||||
.build_request_body(&self.model_id, &request, &self.capability);
|
||||
let body_shape = request_body_shape_payload(&body);
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_body_build_done",
|
||||
json!({
|
||||
"elapsed_ms": body_started.elapsed().as_millis() as u64,
|
||||
"body_kind": json_value_kind(&body),
|
||||
"request_shape": body_shape.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
let encode_started = Instant::now();
|
||||
let request_body = match self.encode_request_body(&body, &mut headers) {
|
||||
Ok(body) => body,
|
||||
Err(error) => {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_body_encode_error",
|
||||
json!({
|
||||
"elapsed_ms": encode_started.elapsed().as_millis() as u64,
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let final_request_headers = request_header_diagnostics(&headers);
|
||||
let body_compression = request_body.encoding().to_string();
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_body_encode_done",
|
||||
json!({
|
||||
"elapsed_ms": encode_started.elapsed().as_millis() as u64,
|
||||
"encoding": body_compression.as_str(),
|
||||
"body_compression": body_compression.as_str(),
|
||||
"raw_json_bytes": request_body.raw_json_bytes(),
|
||||
"wire_bytes": request_body.wire_bytes(),
|
||||
"request_shape": body_shape.clone(),
|
||||
"headers": final_request_headers.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
let builder = self.http_client.post(&url).headers(headers);
|
||||
let builder = match request_body {
|
||||
RequestBody::Json(body) => builder.json(&body),
|
||||
RequestBody::CompressedJson { bytes, .. } => builder.body(bytes),
|
||||
};
|
||||
|
||||
let send_started = Instant::now();
|
||||
emit_transport_trace(&request, "transport_http_send_start", json!({}));
|
||||
let response =
|
||||
match response_with_timeout(builder.send(), DEFAULT_STREAM_OPEN_TIMEOUT, "stream_open")
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let response_headers = response_header_diagnostics(response.headers());
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_http_headers_received",
|
||||
json!({
|
||||
"elapsed_ms": send_started.elapsed().as_millis() as u64,
|
||||
"status": response.status().as_u16(),
|
||||
"success": response.status().is_success(),
|
||||
"headers": response_headers,
|
||||
}),
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(error) => {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_http_send_error",
|
||||
json!({
|
||||
"elapsed_ms": send_started.elapsed().as_millis() as u64,
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let retry_after_present = response.headers().get(RETRY_AFTER).is_some();
|
||||
let error = classify_error_response(response).await;
|
||||
let context_length_exceeded = is_context_length_exceeded(&error);
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_http_status_error",
|
||||
json!({
|
||||
"status": status,
|
||||
"retry_after_present": retry_after_present,
|
||||
"api_error_code": api_error_code(&error),
|
||||
"context_length_exceeded": context_length_exceeded,
|
||||
"provider_usage_absent": context_length_exceeded,
|
||||
"request_shape": body_shape.clone(),
|
||||
"request_headers": final_request_headers.clone(),
|
||||
"body_compression": body_compression.as_str(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_stream_ready",
|
||||
json!({
|
||||
"elapsed_ms": total_started.elapsed().as_millis() as u64,
|
||||
}),
|
||||
);
|
||||
|
||||
let scheme = self.scheme.clone();
|
||||
let status = response.status().as_u16();
|
||||
let response_headers = response_header_diagnostics(response.headers());
|
||||
let transport_trace = request.transport_trace.clone();
|
||||
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
|
||||
let event_stream = byte_stream.eventsource();
|
||||
|
||||
// scheme 固有のパース状態をストリーム単位で保持する
|
||||
let mut state = <S::State as Default>::default();
|
||||
|
||||
let stream = event_stream
|
||||
.map(move |result| match result {
|
||||
Ok(frame) => match scheme.parse_sse(&frame.event, &frame.data, &mut state) {
|
||||
Ok(events) => Ok(events),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Err(e) => {
|
||||
let source = e.to_string();
|
||||
let message = sse_error_context(status, &response_headers, &source);
|
||||
if let Some(trace) = &transport_trace {
|
||||
trace.emit(
|
||||
"transport_sse_parse_error",
|
||||
json!({
|
||||
"status": status,
|
||||
"headers": response_headers.clone(),
|
||||
"error": source,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(ClientError::Sse(message))
|
||||
}
|
||||
})
|
||||
.map(|res| {
|
||||
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
|
||||
Ok(events) => Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
|
||||
Err(e) => Box::pin(futures::stream::once(async move { Err(e) })),
|
||||
};
|
||||
s
|
||||
})
|
||||
.flatten();
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestAuthProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for TestAuthProvider {
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError> {
|
||||
Ok(vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_static("Bearer test-token"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("chatgpt-account-id"),
|
||||
HeaderValue::from_static("account-1"),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestScheme;
|
||||
|
||||
impl Scheme for TestScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://example.test"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/responses".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
_capability: &ModelCapability,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"model": model_id,
|
||||
"input_len": request.items.len(),
|
||||
"prompt_cache_key": request.cache_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
_data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
ModelCapability::minimal()
|
||||
}
|
||||
}
|
||||
|
||||
fn transport(auth: ResolvedAuth) -> HttpTransport<TestScheme> {
|
||||
HttpTransport::new(
|
||||
TestScheme,
|
||||
"gpt-test",
|
||||
"https://example.test",
|
||||
auth,
|
||||
ModelCapability::minimal(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_error_context_includes_response_headers() {
|
||||
let headers = json!({
|
||||
"content_type": "application/octet-stream",
|
||||
"content_encoding": "gzip",
|
||||
"transfer_encoding": "chunked",
|
||||
"content_length": "123",
|
||||
});
|
||||
|
||||
let message = sse_error_context(200, &headers, "stream did not contain valid UTF-8");
|
||||
|
||||
assert!(message.contains("HTTP 200"));
|
||||
assert!(message.contains("stream did not contain valid UTF-8"));
|
||||
assert!(message.contains("content-type=application/octet-stream"));
|
||||
assert!(message.contains("content-encoding=gzip"));
|
||||
assert!(message.contains("transfer-encoding=chunked"));
|
||||
assert!(message.contains("content-length=123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_header_diagnostics_redacts_to_safe_header_subset() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("identity"));
|
||||
headers.insert("authorization", HeaderValue::from_static("Bearer secret"));
|
||||
|
||||
let diagnostics = response_header_diagnostics(&headers);
|
||||
|
||||
assert_eq!(diagnostics["content_type"], "text/event-stream");
|
||||
assert_eq!(diagnostics["content_encoding"], "identity");
|
||||
assert!(diagnostics.get("authorization").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_body_shape_counts_reasoning_encrypted_content() {
|
||||
let payload = request_body_shape_payload(&json!({
|
||||
"reasoning": { "summary": "auto" },
|
||||
"input": [
|
||||
{ "type": "message", "role": "user", "content": [] },
|
||||
{ "type": "reasoning", "encrypted_content": "abc", "summary": [] },
|
||||
{ "type": "reasoning", "encrypted_content": "defgh", "summary": [] }
|
||||
]
|
||||
}));
|
||||
assert_eq!(payload["items_len"], 3);
|
||||
assert_eq!(payload["reasoning_items"], 2);
|
||||
assert_eq!(payload["reasoning_encrypted_content_count"], 2);
|
||||
assert_eq!(payload["reasoning_encrypted_content_bytes"], 8);
|
||||
assert!(payload["items_json_bytes"].as_u64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_timeout_returns_retryable_lifecycle_timeout() {
|
||||
let err = response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
"stream_open",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(crate::llm_client::error::is_retryable(&err));
|
||||
assert!(matches!(
|
||||
err,
|
||||
ClientError::Timeout {
|
||||
phase: "stream_open",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_policy_adds_conversation_headers_and_zstd_body() {
|
||||
let transport = transport(ResolvedAuth::Custom(Arc::new(TestAuthProvider)))
|
||||
.with_transport_policy(TransportPolicy::openai_compatible_zstd());
|
||||
let request = Request::new().user("hello").cache_key("segment-123");
|
||||
let mut headers = transport.build_headers().await.unwrap();
|
||||
transport
|
||||
.apply_stream_headers(&mut headers, &request)
|
||||
.unwrap();
|
||||
let body = transport.scheme.build_request_body(
|
||||
&transport.model_id,
|
||||
&request,
|
||||
&transport.capability,
|
||||
);
|
||||
let encoded = transport.encode_request_body(&body, &mut headers).unwrap();
|
||||
|
||||
assert_eq!(headers.get(ACCEPT).unwrap(), "text/event-stream");
|
||||
assert_eq!(headers.get("session-id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get("thread-id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get("session_id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get("x-client-request-id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "zstd");
|
||||
|
||||
let diagnostics = request_header_diagnostics(&headers);
|
||||
assert_eq!(diagnostics["content_type"], "application/json");
|
||||
assert_eq!(diagnostics["content_encoding"], "zstd");
|
||||
assert_eq!(diagnostics["accept"], "text/event-stream");
|
||||
assert!(diagnostics["session_id_present"].as_bool().unwrap());
|
||||
assert!(diagnostics["thread_id_present"].as_bool().unwrap());
|
||||
assert!(diagnostics["legacy_session_id_present"].as_bool().unwrap());
|
||||
assert!(
|
||||
diagnostics["x_client_request_id_present"]
|
||||
.as_bool()
|
||||
.unwrap()
|
||||
);
|
||||
assert!(diagnostics["chatgpt_account_id_present"].as_bool().unwrap());
|
||||
|
||||
let RequestBody::CompressedJson {
|
||||
bytes: compressed,
|
||||
raw_json_bytes,
|
||||
} = encoded
|
||||
else {
|
||||
panic!("transport policy should zstd-compress request body");
|
||||
};
|
||||
assert!(raw_json_bytes > 0);
|
||||
let decoded = zstd::stream::decode_all(std::io::Cursor::new(compressed)).unwrap();
|
||||
let decoded: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
|
||||
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standard_policy_does_not_get_conversation_headers_or_compression() {
|
||||
let transport = transport(ResolvedAuth::ApiKey("api-key".to_string()));
|
||||
let request = Request::new().user("hello").cache_key("segment-123");
|
||||
let mut headers = transport.build_headers().await.unwrap();
|
||||
transport
|
||||
.apply_stream_headers(&mut headers, &request)
|
||||
.unwrap();
|
||||
let body = transport.scheme.build_request_body(
|
||||
&transport.model_id,
|
||||
&request,
|
||||
&transport.capability,
|
||||
);
|
||||
let encoded = transport.encode_request_body(&body, &mut headers).unwrap();
|
||||
|
||||
assert_eq!(headers.get(ACCEPT).unwrap(), "text/event-stream");
|
||||
assert!(headers.get("session-id").is_none());
|
||||
assert!(headers.get("thread-id").is_none());
|
||||
assert!(headers.get("session_id").is_none());
|
||||
assert!(headers.get("x-client-request-id").is_none());
|
||||
assert!(headers.get(CONTENT_ENCODING).is_none());
|
||||
|
||||
let RequestBody::Json(decoded) = encoded else {
|
||||
panic!("standard transport policy should keep request body as JSON");
|
||||
};
|
||||
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
//! LLM Client Common Types
|
||||
//!
|
||||
//! Core conversation types for LLM interaction.
|
||||
//! The core abstraction is `Item` which represents different types of conversation elements:
|
||||
//! - Message items (user/assistant messages with content parts)
|
||||
//! - ToolCall items (tool invocations)
|
||||
//! - ToolResult items (tool results)
|
||||
//! - Reasoning items (extended thinking)
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use crate::tool::Attachment;
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
pub(crate) fn image_data_url(media_type: &str, data: &[u8]) -> String {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
format!("data:{media_type};base64,{encoded}")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Item - The core unit of conversation
|
||||
// ============================================================================
|
||||
|
||||
/// Item ID type for tracking items in a conversation
|
||||
pub type ItemId = String;
|
||||
|
||||
/// Call ID type for linking function calls to their outputs
|
||||
pub type CallId = String;
|
||||
|
||||
/// Callback sink for request-local transport lifecycle diagnostics.
|
||||
///
|
||||
/// This is carried on [`Request`] so generic [`crate::llm_client::LlmClient`]
|
||||
/// implementations can emit fine-grained transport milestones without widening
|
||||
/// the trait method signature. The callback must never receive request body
|
||||
/// contents or secret header values.
|
||||
#[derive(Clone)]
|
||||
pub struct RequestTrace {
|
||||
callback: Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl RequestTrace {
|
||||
pub fn new(callback: impl Fn(&str, serde_json::Value) + Send + Sync + 'static) -> Self {
|
||||
Self {
|
||||
callback: Arc::new(callback),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit(&self, label: &str, data: serde_json::Value) {
|
||||
(self.callback)(label, data);
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RequestTrace {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("RequestTrace").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversation item - the primary unit of conversation history
|
||||
///
|
||||
/// Items represent discrete elements in a conversation. Tool calls and reasoning
|
||||
/// are first-class items rather than parts of messages.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use agen::Item;
|
||||
///
|
||||
/// let user = Item::user_message("Hello!");
|
||||
/// let assistant = Item::assistant_message("Hi there!");
|
||||
/// let call = Item::tool_call("call_123", "get_weather", json!({"city": "Tokyo"}));
|
||||
/// let result = Item::tool_result("call_123", "Sunny, 25°C");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Item {
|
||||
/// User or assistant message with content parts
|
||||
Message {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Message role
|
||||
role: Role,
|
||||
/// Content parts
|
||||
content: Vec<ContentPart>,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
|
||||
/// Tool call from the assistant
|
||||
ToolCall {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Call ID for linking to result
|
||||
call_id: CallId,
|
||||
/// Tool name
|
||||
name: String,
|
||||
/// Tool arguments as JSON string
|
||||
arguments: String,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
|
||||
/// Tool call result
|
||||
ToolResult {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Call ID linking to the tool call
|
||||
call_id: CallId,
|
||||
/// Short summary (always kept in history, survives pruning)
|
||||
summary: String,
|
||||
/// Detailed output (removed by pruning when old enough)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
/// Whether the tool result represents an execution error.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
/// Durable binary details (removed with `content` by normal pruning).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<Attachment>,
|
||||
},
|
||||
|
||||
/// Reasoning/thinking item
|
||||
Reasoning {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Reasoning text(reasoning body, `reasoning_text.delta` の累積)
|
||||
text: String,
|
||||
/// Reasoning summary(OpenAI Responses の `summary_text[]` を格納。
|
||||
/// 他 scheme は空)
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
summary: Vec<String>,
|
||||
/// サーバから返された暗号化済み reasoning blob。ZDR / `store=false`
|
||||
/// 運用で stateless に再送するときそのまま添える必要がある。
|
||||
/// Anthropic の `redacted_thinking.data` もここに格納する。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
encrypted_content: Option<String>,
|
||||
/// Anthropic extended thinking の `signature`。新世代 Claude
|
||||
/// (Opus 4.5+/Sonnet 4.6+) では同一論理ターン内の `thinking`
|
||||
/// ブロックを送り返す際に必須。改ざん検知に使われる。他 scheme
|
||||
/// では `None`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Item {
|
||||
// ========================================================================
|
||||
// Message constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a system message item with text content.
|
||||
///
|
||||
/// System items in history are sent as `role: "system"` on OpenAI,
|
||||
/// and as `role: "user"` on Anthropic/Gemini (which lack a system
|
||||
/// role in conversation items).
|
||||
pub fn system_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::System,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message item with text content
|
||||
pub fn user_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::User,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message item with multiple content parts
|
||||
pub fn user_message_parts(parts: Vec<ContentPart>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::User,
|
||||
content: parts,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message item with text content
|
||||
pub fn assistant_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message item with multiple content parts
|
||||
pub fn assistant_message_parts(parts: Vec<ContentPart>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::Assistant,
|
||||
content: parts,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Tool call constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a tool call item
|
||||
pub fn tool_call(
|
||||
call_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
arguments: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::ToolCall {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
name: name.into(),
|
||||
arguments: arguments.into(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool call item from a JSON value
|
||||
pub fn tool_call_json(
|
||||
call_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
arguments: serde_json::Value,
|
||||
) -> Self {
|
||||
Self::tool_call(call_id, name, arguments.to_string())
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary only (no content).
|
||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, false)
|
||||
}
|
||||
|
||||
/// Create an error tool result item with summary only (no content).
|
||||
pub fn tool_result_error(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, true)
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary, optional content, and error flag.
|
||||
pub fn tool_result_item(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
|
||||
}
|
||||
|
||||
/// Create a tool result item with durable, prunable structured attachments.
|
||||
pub fn tool_result_item_with_attachments(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
attachments: Vec<Attachment>,
|
||||
) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content,
|
||||
is_error,
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary and content.
|
||||
pub fn tool_result_with_content(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::tool_result_item(call_id, summary, Some(content.into()), false)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Reasoning constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a reasoning item
|
||||
pub fn reasoning(text: impl Into<String>) -> Self {
|
||||
Self::Reasoning {
|
||||
id: None,
|
||||
text: text.into(),
|
||||
summary: Vec::new(),
|
||||
encrypted_content: None,
|
||||
signature: None,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set reasoning summary on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_reasoning_summary(mut self, new_summary: Vec<String>) -> Self {
|
||||
if let Self::Reasoning { summary, .. } = &mut self {
|
||||
*summary = new_summary;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set `encrypted_content` on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_encrypted_content(mut self, content: impl Into<String>) -> Self {
|
||||
if let Self::Reasoning {
|
||||
encrypted_content, ..
|
||||
} = &mut self
|
||||
{
|
||||
*encrypted_content = Some(content.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set Anthropic `signature` on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_signature(mut self, sig: impl Into<String>) -> Self {
|
||||
if let Self::Reasoning { signature, .. } = &mut self {
|
||||
*signature = Some(sig.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Builder methods
|
||||
// ========================================================================
|
||||
|
||||
/// Set the item ID
|
||||
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::ToolCall { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::ToolResult { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::Reasoning { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the item status
|
||||
pub fn with_status(mut self, new_status: ItemStatus) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { status, .. } => *status = Some(new_status),
|
||||
Self::ToolCall { status, .. } => *status = Some(new_status),
|
||||
Self::ToolResult { .. } => {} // Result items don't have status
|
||||
Self::Reasoning { status, .. } => *status = Some(new_status),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Accessors
|
||||
// ========================================================================
|
||||
|
||||
/// Get the item ID if set
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Message { id, .. } => id.as_deref(),
|
||||
Self::ToolCall { id, .. } => id.as_deref(),
|
||||
Self::ToolResult { id, .. } => id.as_deref(),
|
||||
Self::Reasoning { id, .. } => id.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the item type as a string
|
||||
pub fn item_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Message { .. } => "message",
|
||||
Self::ToolCall { .. } => "tool_call",
|
||||
Self::ToolResult { .. } => "tool_result",
|
||||
Self::Reasoning { .. } => "reasoning",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a user message
|
||||
pub fn is_user_message(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Message {
|
||||
role: Role::User,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is an assistant message
|
||||
pub fn is_assistant_message(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Message {
|
||||
role: Role::Assistant,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is a tool call
|
||||
pub fn is_tool_call(&self) -> bool {
|
||||
matches!(self, Self::ToolCall { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a tool result
|
||||
pub fn is_tool_result(&self) -> bool {
|
||||
matches!(self, Self::ToolResult { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a reasoning item
|
||||
pub fn is_reasoning(&self) -> bool {
|
||||
matches!(self, Self::Reasoning { .. })
|
||||
}
|
||||
|
||||
/// Get text content if this is a simple text message
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Message { content, .. } if content.len() == 1 => match &content[0] {
|
||||
ContentPart::Text { text } => Some(text),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a ToolCall `arguments` string into a JSON object.
|
||||
///
|
||||
/// Tool call arguments must be a JSON object at the provider API level
|
||||
/// (Anthropic rejects non-object `tool_use.input`). This helper normalizes
|
||||
/// anything that is not a JSON object — empty string, the literal `"null"`,
|
||||
/// arrays, scalars, or parse failures — to an empty object `{}`.
|
||||
pub fn parse_tool_arguments(arguments: &str) -> serde_json::Value {
|
||||
match serde_json::from_str::<serde_json::Value>(arguments) {
|
||||
Ok(value) if value.is_object() => value,
|
||||
_ => serde_json::Value::Object(serde_json::Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Parts - Components within message items
|
||||
// ============================================================================
|
||||
|
||||
/// Content part within a message item
|
||||
///
|
||||
/// Text content is role-agnostic; the containing Item's Role determines
|
||||
/// whether it's user input or assistant output.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentPart {
|
||||
/// Text content
|
||||
Text {
|
||||
/// The text content
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Refusal content (for assistant messages)
|
||||
Refusal {
|
||||
/// The refusal message
|
||||
refusal: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContentPart {
|
||||
/// Create a text part
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self::Text { text: text.into() }
|
||||
}
|
||||
|
||||
/// Create a refusal part
|
||||
pub fn refusal(refusal: impl Into<String>) -> Self {
|
||||
Self::Refusal {
|
||||
refusal: refusal.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a textual projection of the content part.
|
||||
pub fn as_text(&self) -> &str {
|
||||
match self {
|
||||
Self::Text { text } => text,
|
||||
Self::Refusal { refusal } => refusal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Role and Status
|
||||
// ============================================================================
|
||||
|
||||
/// Message role
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
/// User
|
||||
User,
|
||||
/// Assistant
|
||||
Assistant,
|
||||
/// System (for system prompts, not typically used in items)
|
||||
System,
|
||||
}
|
||||
|
||||
/// Item status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ItemStatus {
|
||||
/// Item is being generated
|
||||
InProgress,
|
||||
/// Item completed successfully
|
||||
Completed,
|
||||
/// Item was truncated (e.g., max tokens)
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Types
|
||||
// ============================================================================
|
||||
|
||||
/// LLM Request
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Request {
|
||||
/// System prompt (instructions)
|
||||
pub system_prompt: Option<String>,
|
||||
/// Input items (conversation history)
|
||||
pub items: Vec<Item>,
|
||||
/// Tool definitions
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
/// Request configuration
|
||||
pub config: RequestConfig,
|
||||
/// Index into `items` marking the end of a stable, cacheable prefix.
|
||||
///
|
||||
/// Higher layers that know about durable prefix boundaries (e.g. a
|
||||
/// post-compaction summary) set this so that caching-aware providers
|
||||
/// (Anthropic today) can place a long-lived cache breakpoint there.
|
||||
/// Providers without prompt caching ignore the field.
|
||||
pub cache_anchor: Option<usize>,
|
||||
/// 会話単位の安定キー。`prompt_cache_key` として送られる
|
||||
/// (OpenAI Responses)。明示キーを必要とする backend では、
|
||||
/// 呼び出し側が安定した conversation identifier を渡す。
|
||||
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
|
||||
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
||||
/// `prompt_cache_key` を持たない provider は無視する。
|
||||
pub cache_key: Option<String>,
|
||||
/// Request-local diagnostics sink for transport lifecycle tracing.
|
||||
#[doc(hidden)]
|
||||
pub transport_trace: Option<RequestTrace>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Create a new empty request
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the system prompt
|
||||
pub fn system(mut self, prompt: impl Into<String>) -> Self {
|
||||
self.system_prompt = Some(prompt.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a user message
|
||||
pub fn user(mut self, content: impl Into<String>) -> Self {
|
||||
self.items.push(Item::user_message(content));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an assistant message
|
||||
pub fn assistant(mut self, content: impl Into<String>) -> Self {
|
||||
self.items.push(Item::assistant_message(content));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an item
|
||||
pub fn item(mut self, item: Item) -> Self {
|
||||
self.items.push(item);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add multiple items
|
||||
pub fn items(mut self, items: impl IntoIterator<Item = Item>) -> Self {
|
||||
self.items.extend(items);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a tool definition
|
||||
pub fn tool(mut self, tool: ToolDefinition) -> Self {
|
||||
self.tools.push(tool);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the request config
|
||||
pub fn config(mut self, config: RequestConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a request-local transport trace callback.
|
||||
pub fn transport_trace(
|
||||
mut self,
|
||||
callback: impl Fn(&str, serde_json::Value) + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
self.transport_trace = Some(RequestTrace::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set max tokens
|
||||
pub fn max_tokens(mut self, max_tokens: u32) -> Self {
|
||||
self.config.max_tokens = Some(max_tokens);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set temperature
|
||||
pub fn temperature(mut self, temperature: f32) -> Self {
|
||||
self.config.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_p
|
||||
pub fn top_p(mut self, top_p: f32) -> Self {
|
||||
self.config.top_p = Some(top_p);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_k
|
||||
pub fn top_k(mut self, top_k: u32) -> Self {
|
||||
self.config.top_k = Some(top_k);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a stop sequence
|
||||
pub fn stop_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.config.stop_sequences.push(sequence.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the conversation cache key.
|
||||
///
|
||||
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
|
||||
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
|
||||
self.cache_key = Some(key.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Definition
|
||||
// ============================================================================
|
||||
|
||||
/// Tool (function) definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
/// Tool name
|
||||
pub name: String,
|
||||
/// Tool description
|
||||
pub description: Option<String>,
|
||||
/// Input schema (JSON Schema)
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
/// Create a new tool definition
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the description
|
||||
pub fn description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = Some(desc.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the input schema
|
||||
pub fn input_schema(mut self, schema: serde_json::Value) -> Self {
|
||||
self.input_schema = schema;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Config
|
||||
// ============================================================================
|
||||
|
||||
/// Request configuration
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RequestConfig {
|
||||
/// Maximum tokens to generate
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Temperature (randomness)
|
||||
pub temperature: Option<f32>,
|
||||
/// Top P (nucleus sampling)
|
||||
pub top_p: Option<f32>,
|
||||
/// Top K
|
||||
pub top_k: Option<u32>,
|
||||
/// Stop sequences
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// Reasoning / extended-thinking 制御(共通型、scheme 側で各社形式に投影)。
|
||||
///
|
||||
/// `None` のときは何も送らない。`Some` でも scheme の
|
||||
/// `ModelCapability::reasoning` が `None` なら無視される。
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<crate::llm_client::capability::ReasoningControl>,
|
||||
}
|
||||
|
||||
impl RequestConfig {
|
||||
/// Create a new default config
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set max tokens
|
||||
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
|
||||
self.max_tokens = Some(max_tokens);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set temperature
|
||||
pub fn with_temperature(mut self, temperature: f32) -> Self {
|
||||
self.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_p
|
||||
pub fn with_top_p(mut self, top_p: f32) -> Self {
|
||||
self.top_p = Some(top_p);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_k
|
||||
pub fn with_top_k(mut self, top_k: u32) -> Self {
|
||||
self.top_k = Some(top_k);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a stop sequence
|
||||
pub fn with_stop_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.stop_sequences.push(sequence.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parse_tool_arguments_tests {
|
||||
use super::parse_tool_arguments;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn empty_object() -> Value {
|
||||
Value::Object(serde_json::Map::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments(""), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_null_normalizes_to_object() {
|
||||
// 既存セッションに残っている "null" が resume 時に復旧できること
|
||||
assert_eq!(parse_tool_arguments("null"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("[1, 2, 3]"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("42"), empty_object());
|
||||
assert_eq!(parse_tool_arguments("\"str\""), empty_object());
|
||||
assert_eq!(parse_tool_arguments("true"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("{not json"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_object_passes_through() {
|
||||
assert_eq!(
|
||||
parse_tool_arguments(r#"{"city":"Tokyo","days":3}"#),
|
||||
json!({"city": "Tokyo", "days": 3}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_passes_through() {
|
||||
assert_eq!(parse_tool_arguments("{}"), empty_object());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Message and Item Types
|
||||
//!
|
||||
//! Core types for representing conversation items.
|
||||
//!
|
||||
//! The primary type is [`Item`], which represents different kinds of conversation
|
||||
//! elements: messages, tool calls, tool results, and reasoning.
|
||||
|
||||
// Re-export all types from llm_client::types
|
||||
pub use crate::llm_client::types::{ContentPart, Item, Role};
|
||||
|
||||
/// Convenience alias
|
||||
///
|
||||
/// Messages are just one type of Item.
|
||||
pub type Message = Item;
|
||||
@@ -0,0 +1,275 @@
|
||||
//! `~/.codex/auth.json` の読み書き。
|
||||
//!
|
||||
//! Codex CLI と schema を共有するが、知らないフィールドを失わないよう
|
||||
//! ファイル全体を `serde_json::Value` で保持し、必要箇所
|
||||
//! のみアクセスする。書込は `mode 0o600` を再設定(Codex CLI 同様)、
|
||||
//! ファイルロックは取らない(manager 側で guarded reload)。
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::error::CodexAuthError;
|
||||
|
||||
/// auth.json から取り出した使い回す情報のスナップショット。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthSnapshot {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub account_id: String,
|
||||
pub id_token: String,
|
||||
pub last_refresh: Option<DateTime<Utc>>,
|
||||
/// 書き戻し時に他のフィールドを失わないため、ファイル全体を保持する。
|
||||
pub raw: Value,
|
||||
}
|
||||
|
||||
impl AuthSnapshot {
|
||||
/// `Value` から必要フィールドを抽出。欠落・型不一致は `MalformedAuthJson`。
|
||||
pub fn from_value(raw: Value) -> Result<Self, CodexAuthError> {
|
||||
let tokens = raw
|
||||
.get("tokens")
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing 'tokens'".into()))?;
|
||||
|
||||
let access_token = tokens
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.access_token".into()))?
|
||||
.to_string();
|
||||
|
||||
let refresh_token = tokens
|
||||
.get("refresh_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
CodexAuthError::MalformedAuthJson("missing tokens.refresh_token".into())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let id_token = tokens
|
||||
.get("id_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.id_token".into()))?
|
||||
.to_string();
|
||||
|
||||
// account_id は tokens.account_id を優先、無ければ id_token JWT 由来
|
||||
let account_id = tokens
|
||||
.get("account_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| super::jwt::parse_chatgpt_claims(&id_token).and_then(|c| c.account_id))
|
||||
.ok_or_else(|| {
|
||||
CodexAuthError::MalformedAuthJson(
|
||||
"missing account_id in both tokens and id_token claims".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let last_refresh = raw
|
||||
.get("last_refresh")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
Ok(Self {
|
||||
access_token,
|
||||
refresh_token,
|
||||
account_id,
|
||||
id_token,
|
||||
last_refresh,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// auth.json を読む。存在しなければ `NotLoggedIn`。
|
||||
pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let bytes = match tokio::fs::read(path).await {
|
||||
Ok(b) => b,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(CodexAuthError::NotLoggedIn(path.to_path_buf()));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(CodexAuthError::Io(format!(
|
||||
"failed to read {}: {err}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let raw: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| CodexAuthError::MalformedAuthJson(format!("json parse: {e}")))?;
|
||||
AuthSnapshot::from_value(raw)
|
||||
}
|
||||
|
||||
/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と
|
||||
/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。
|
||||
///
|
||||
/// 並行する Codex CLI / 別プロセスが先に refresh していた場合の
|
||||
/// fields を保護するため、書込前に再 load して merge する。
|
||||
pub async fn persist_refreshed(
|
||||
path: &Path,
|
||||
new_id_token: Option<String>,
|
||||
new_access_token: Option<String>,
|
||||
new_refresh_token: Option<String>,
|
||||
) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut current = load(path).await?;
|
||||
let raw = &mut current.raw;
|
||||
let tokens = raw
|
||||
.get_mut("tokens")
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("tokens not an object".into()))?;
|
||||
if let Some(t) = new_id_token {
|
||||
tokens.insert("id_token".into(), Value::String(t));
|
||||
}
|
||||
if let Some(t) = new_access_token {
|
||||
tokens.insert("access_token".into(), Value::String(t));
|
||||
}
|
||||
if let Some(t) = new_refresh_token {
|
||||
tokens.insert("refresh_token".into(), Value::String(t));
|
||||
}
|
||||
raw.as_object_mut()
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("auth.json not an object".into()))?
|
||||
.insert(
|
||||
"last_refresh".into(),
|
||||
Value::String(Utc::now().to_rfc3339()),
|
||||
);
|
||||
|
||||
write_atomic(path, raw)?;
|
||||
AuthSnapshot::from_value(raw.clone())
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| CodexAuthError::Io(format!("create_dir_all {}: {e}", parent.display())))?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(value)
|
||||
.map_err(|e| CodexAuthError::Io(format!("serialize: {e}")))?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.truncate(true).write(true).create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options
|
||||
.open(path)
|
||||
.map_err(|e| CodexAuthError::Io(format!("open {}: {e}", path.display())))?;
|
||||
file.write_all(&json)
|
||||
.map_err(|e| CodexAuthError::Io(format!("write {}: {e}", path.display())))?;
|
||||
file.flush()
|
||||
.map_err(|e| CodexAuthError::Io(format!("flush {}: {e}", path.display())))?;
|
||||
|
||||
// 既存ファイルが緩いパーミッションだった場合に備えて 0o600 を強制し直す。
|
||||
// OpenOptions の `mode()` は新規作成時しか効かないため。
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| CodexAuthError::Io(format!("chmod {}: {e}", path.display())))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write_auth_json(dir: &Path, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join("auth.json");
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
r#"{
|
||||
"auth_mode":"ChatgptAuthTokens",
|
||||
"tokens":{
|
||||
"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjLTEifX0.s",
|
||||
"access_token":"acc",
|
||||
"refresh_token":"ref",
|
||||
"account_id":"acc-1"
|
||||
},
|
||||
"last_refresh":"2026-04-20T00:00:00Z",
|
||||
"OPENAI_API_KEY":"sk-extra"
|
||||
}"#,
|
||||
);
|
||||
let snap = load(&path).await.unwrap();
|
||||
assert_eq!(snap.access_token, "acc");
|
||||
assert_eq!(snap.refresh_token, "ref");
|
||||
assert_eq!(snap.account_id, "acc-1");
|
||||
assert!(snap.last_refresh.is_some());
|
||||
// 未知フィールドが raw に保持されている
|
||||
assert_eq!(
|
||||
snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str),
|
||||
Some("sk-extra")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_id_falls_back_to_jwt_claim() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// tokens.account_id を欠落させ、id_token JWT 内 claim から拾う
|
||||
let id_token = "h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiZnJvbS1qd3QifX0.s";
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
&format!(
|
||||
r#"{{ "tokens": {{ "id_token":"{id_token}", "access_token":"a", "refresh_token":"r" }} }}"#
|
||||
),
|
||||
);
|
||||
let snap = load(&path).await.unwrap();
|
||||
assert_eq!(snap.account_id, "from-jwt");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_file_returns_not_logged_in() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = load(&dir.path().join("nope.json")).await.unwrap_err();
|
||||
assert!(matches!(err, CodexAuthError::NotLoggedIn(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_preserves_unknown_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
r#"{
|
||||
"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"old-acc","refresh_token":"old-ref","account_id":"a"},
|
||||
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
||||
}"#,
|
||||
);
|
||||
let updated =
|
||||
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.access_token, "new-acc");
|
||||
assert_eq!(updated.refresh_token, "new-ref");
|
||||
// 未知フィールド agent_identity が保たれる
|
||||
let on_disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(on_disk.get("agent_identity").is_some());
|
||||
assert!(on_disk.get("last_refresh").is_some());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn write_uses_mode_600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
r#"{"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"a","refresh_token":"r","account_id":"a"}}"#,
|
||||
);
|
||||
// 既存ファイルを 644 に変えてから persist → 600 に直るか
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
persist_refreshed(&path, None, Some("a2".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Codex OAuth サブモジュール内部のエラー型。
|
||||
//!
|
||||
//! `LlmClient` 境界に渡す際は `to_client_error` で `ClientError` に
|
||||
//! 変換する。`Permanent` 系(refresh_token 失効)は `codex login` の
|
||||
//! 再実行案内付きメッセージにする。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::llm_client::ClientError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CodexAuthError {
|
||||
#[error(
|
||||
"not logged in to ChatGPT: {0} not found. Run `codex login` and ensure cli_auth_credentials_store = \"file\"."
|
||||
)]
|
||||
NotLoggedIn(PathBuf),
|
||||
|
||||
#[error("malformed ~/.codex/auth.json: {0}")]
|
||||
MalformedAuthJson(String),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
|
||||
#[error("token refresh failed (transient): {0}")]
|
||||
RefreshTransient(String),
|
||||
|
||||
/// refresh_token が永続的に失効。再ログインが必要。
|
||||
#[error("token refresh failed permanently ({reason:?}): {message}")]
|
||||
RefreshPermanent {
|
||||
reason: PermanentReason,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("invalid header value: {0}")]
|
||||
InvalidHeader(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermanentReason {
|
||||
Expired,
|
||||
Reused,
|
||||
Revoked,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl CodexAuthError {
|
||||
/// `LlmClient` トランスポート境界向けに変換する。
|
||||
pub fn to_client_error(self) -> ClientError {
|
||||
match self {
|
||||
CodexAuthError::NotLoggedIn(_)
|
||||
| CodexAuthError::MalformedAuthJson(_)
|
||||
| CodexAuthError::Io(_)
|
||||
| CodexAuthError::InvalidHeader(_) => ClientError::Config(self.to_string()),
|
||||
CodexAuthError::RefreshTransient(msg) => ClientError::Api {
|
||||
status: None,
|
||||
code: Some("refresh_transient".into()),
|
||||
message: msg,
|
||||
retry_after: None,
|
||||
},
|
||||
CodexAuthError::RefreshPermanent { reason, message } => ClientError::Api {
|
||||
status: Some(401),
|
||||
code: Some(match reason {
|
||||
PermanentReason::Expired => "refresh_token_expired".into(),
|
||||
PermanentReason::Reused => "refresh_token_reused".into(),
|
||||
PermanentReason::Revoked => "refresh_token_invalidated".into(),
|
||||
PermanentReason::Other => "refresh_token_failed".into(),
|
||||
}),
|
||||
message: format!("{message}. Please run `codex login` again."),
|
||||
retry_after: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! JWT payload の最小限のパース(署名検証なし)。
|
||||
//!
|
||||
//! Codex CLI と同じく、access_token / id_token の payload を base64url
|
||||
//! デコードして `exp` や ChatGPT 固有 claims を取り出すためだけに使う。
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// `Authorization: Bearer` で送る access_token JWT の `exp` を読む。
|
||||
pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Claims {
|
||||
exp: Option<i64>,
|
||||
}
|
||||
let claims: Claims = decode_payload(jwt).ok()?;
|
||||
DateTime::<Utc>::from_timestamp(claims.exp?, 0)
|
||||
}
|
||||
|
||||
/// id_token JWT から ChatGPT 固有 claims を取り出す。
|
||||
pub fn parse_chatgpt_claims(jwt: &str) -> Option<ChatGptClaims> {
|
||||
#[derive(Deserialize)]
|
||||
struct IdClaims {
|
||||
#[serde(rename = "https://api.openai.com/auth", default)]
|
||||
auth: Option<AuthClaims>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct AuthClaims {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
#[serde(default)]
|
||||
chatgpt_account_is_fedramp: bool,
|
||||
}
|
||||
let claims: IdClaims = decode_payload(jwt).ok()?;
|
||||
let auth = claims.auth?;
|
||||
Some(ChatGptClaims {
|
||||
account_id: auth.chatgpt_account_id,
|
||||
is_fedramp: auth.chatgpt_account_is_fedramp,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatGptClaims {
|
||||
pub account_id: Option<String>,
|
||||
pub is_fedramp: bool,
|
||||
}
|
||||
|
||||
fn decode_payload<T: for<'de> Deserialize<'de>>(jwt: &str) -> Result<T, JwtError> {
|
||||
let payload = jwt.split('.').nth(1).ok_or(JwtError::InvalidFormat)?;
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.map_err(|_| JwtError::InvalidBase64)?;
|
||||
serde_json::from_slice(&bytes).map_err(|_| JwtError::InvalidJson)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum JwtError {
|
||||
InvalidFormat,
|
||||
InvalidBase64,
|
||||
InvalidJson,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_jwt(payload: &serde_json::Value) -> String {
|
||||
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(payload).unwrap());
|
||||
format!("h.{payload_b64}.s")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_exp() {
|
||||
let jwt = make_jwt(&serde_json::json!({ "exp": 1_700_000_000_i64 }));
|
||||
let dt = parse_exp(&jwt).unwrap();
|
||||
assert_eq!(dt.timestamp(), 1_700_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chatgpt_claims() {
|
||||
let jwt = make_jwt(&serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-123",
|
||||
"chatgpt_account_is_fedramp": true,
|
||||
}
|
||||
}));
|
||||
let c = parse_chatgpt_claims(&jwt).unwrap();
|
||||
assert_eq!(c.account_id.as_deref(), Some("acc-123"));
|
||||
assert!(c.is_fedramp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_exp_returns_none() {
|
||||
let jwt = make_jwt(&serde_json::json!({}));
|
||||
assert!(parse_exp(&jwt).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_jwt_returns_none() {
|
||||
assert!(parse_exp("not-a-jwt").is_none());
|
||||
assert!(parse_chatgpt_claims("x.y").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! ChatGPT OAuth (`~/.codex/auth.json`) を読んで `Authorization` /
|
||||
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` ヘッダを組み立てる
|
||||
//! [`AuthProvider`] 実装。
|
||||
//!
|
||||
//! 設計:
|
||||
//!
|
||||
//! - HTTP transport は [`AuthProvider`] trait だけを見て、実体である
|
||||
//! [`CodexAuthProvider`] はこの optional module に置く
|
||||
//! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh
|
||||
//! (Codex CLI と同じバッファなし)
|
||||
//! - 並行する Codex CLI / 別プロセスの refresh と取り違えないよう、
|
||||
//! refresh 直前に再 load して account_id 一致を確認(guarded reload)
|
||||
//! - ファイルロックは取らず、書込前に再 load + diff merge で吸収
|
||||
//! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内
|
||||
|
||||
mod auth_json;
|
||||
mod error;
|
||||
mod jwt;
|
||||
mod refresh;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::llm_client::{ClientError, auth::AuthProvider};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use auth_json::AuthSnapshot;
|
||||
use error::CodexAuthError;
|
||||
|
||||
pub use error::PermanentReason;
|
||||
|
||||
/// Codex CLI の `last_refresh` ベース fallback 期限(Codex CLI 準拠で 8 日)。
|
||||
const TOKEN_REFRESH_INTERVAL_DAYS: i64 = 8;
|
||||
|
||||
/// `~/.codex/auth.json` を読んで Codex 互換のヘッダを返す provider。
|
||||
pub struct CodexAuthProvider {
|
||||
auth_path: PathBuf,
|
||||
refresh_endpoint: String,
|
||||
http: reqwest::Client,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CodexAuthProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CodexAuthProvider")
|
||||
.field("auth_path", &self.auth_path)
|
||||
.field("refresh_endpoint", &self.refresh_endpoint)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
cached: Option<AuthSnapshot>,
|
||||
}
|
||||
|
||||
impl CodexAuthProvider {
|
||||
/// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。
|
||||
pub fn from_default_home() -> Result<Self, ClientError> {
|
||||
let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") {
|
||||
PathBuf::from(p)
|
||||
} else {
|
||||
let home =
|
||||
std::env::var("HOME").map_err(|_| ClientError::Config("HOME not set".into()))?;
|
||||
PathBuf::from(home).join(".codex")
|
||||
};
|
||||
Ok(Self::new(codex_home))
|
||||
}
|
||||
|
||||
/// 任意の codex_home から構築する。
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
auth_path: codex_home.join("auth.json"),
|
||||
refresh_endpoint: refresh::REFRESH_URL.to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
state: Arc::new(Mutex::new(State::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// テスト用に refresh エンドポイントを差し替える。
|
||||
#[cfg(test)]
|
||||
fn with_refresh_endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.refresh_endpoint = endpoint.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// テスト用に HTTP クライアントを差し替える。
|
||||
#[cfg(test)]
|
||||
fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http = client;
|
||||
self
|
||||
}
|
||||
|
||||
async fn ensure_fresh(&self) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
// 1. 常にディスクから最新を読む(他プロセスの更新を反映)
|
||||
let snap = auth_json::load(&self.auth_path).await?;
|
||||
|
||||
// 2. stale でなければそのまま返す
|
||||
if !is_stale(&snap) {
|
||||
state.cached = Some(snap.clone());
|
||||
return Ok(snap);
|
||||
}
|
||||
|
||||
// 3. Refresh 直前に再 load し account_id 一致を確認(guarded reload)。
|
||||
// 一致しなければ他プロセスが先に更新済 → 自分は refresh しない
|
||||
let pre_refresh = auth_json::load(&self.auth_path).await?;
|
||||
if !is_stale(&pre_refresh) {
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
return Ok(pre_refresh);
|
||||
}
|
||||
if pre_refresh.account_id != snap.account_id {
|
||||
// account が切り替わった → 新しい auth を採用、refresh はしない
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
return Ok(pre_refresh);
|
||||
}
|
||||
|
||||
// 4. Refresh 実行
|
||||
let refreshed = refresh::request_refresh(
|
||||
&self.http,
|
||||
&self.refresh_endpoint,
|
||||
&pre_refresh.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施)
|
||||
let new_snap = auth_json::persist_refreshed(
|
||||
&self.auth_path,
|
||||
refreshed.id_token,
|
||||
refreshed.access_token,
|
||||
refreshed.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
state.cached = Some(new_snap.clone());
|
||||
Ok(new_snap)
|
||||
}
|
||||
|
||||
fn build_headers(
|
||||
snap: &AuthSnapshot,
|
||||
) -> Result<Vec<(HeaderName, HeaderValue)>, CodexAuthError> {
|
||||
let mut out = Vec::with_capacity(5);
|
||||
|
||||
let auth_val = HeaderValue::from_str(&format!("Bearer {}", snap.access_token))
|
||||
.map_err(|e| CodexAuthError::InvalidHeader(format!("Authorization: {e}")))?;
|
||||
out.push((HeaderName::from_static("authorization"), auth_val));
|
||||
|
||||
let acc_val = HeaderValue::from_str(&snap.account_id)
|
||||
.map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?;
|
||||
out.push((HeaderName::from_static("chatgpt-account-id"), acc_val));
|
||||
|
||||
// Cloudflare WAF は互換 backend アクセス元を `originator` /
|
||||
// `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと
|
||||
// HTML challenge (403) を返されて SSE に到達できない。
|
||||
out.push((
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static("codex_cli_rs"),
|
||||
));
|
||||
out.push((
|
||||
HeaderName::from_static("user-agent"),
|
||||
HeaderValue::from_static("codex_cli_rs/0.60.0"),
|
||||
));
|
||||
|
||||
// FedRAMP 組織は id_token JWT 内の claim で判定
|
||||
if jwt::parse_chatgpt_claims(&snap.id_token)
|
||||
.map(|c| c.is_fedramp)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
out.push((
|
||||
HeaderName::from_static("x-openai-fedramp"),
|
||||
HeaderValue::from_static("true"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for CodexAuthProvider {
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError> {
|
||||
let snap = self
|
||||
.ensure_fresh()
|
||||
.await
|
||||
.map_err(CodexAuthError::to_client_error)?;
|
||||
Self::build_headers(&snap).map_err(CodexAuthError::to_client_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// `access_token` の JWT `exp` を見て、期限切れなら true。
|
||||
/// `exp` が読めない場合は `last_refresh + 8 日` で判定(Codex CLI と同じ)。
|
||||
fn is_stale(snap: &AuthSnapshot) -> bool {
|
||||
if let Some(exp) = jwt::parse_exp(&snap.access_token) {
|
||||
return exp <= Utc::now();
|
||||
}
|
||||
match snap.last_refresh {
|
||||
Some(last) => last < Utc::now() - Duration::days(TOKEN_REFRESH_INTERVAL_DAYS),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path as url_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_jwt(payload: serde_json::Value) -> String {
|
||||
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&payload).unwrap());
|
||||
format!("h.{p}.s")
|
||||
}
|
||||
|
||||
fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf {
|
||||
let id_token = make_jwt(json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-xyz",
|
||||
"chatgpt_account_is_fedramp": fedramp,
|
||||
}
|
||||
}));
|
||||
let access_token = make_jwt(json!({ "exp": exp }));
|
||||
let auth = json!({
|
||||
"tokens": {
|
||||
"id_token": id_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh,
|
||||
"account_id": "acc-xyz",
|
||||
},
|
||||
"last_refresh": "2026-04-20T00:00:00Z",
|
||||
});
|
||||
let path = dir.join("auth.json");
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_headers_for_fresh_token() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 期限を遠い未来に
|
||||
write_auth(dir.path(), Utc::now().timestamp() + 3600, false, "rt");
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
|
||||
let headers = provider.headers().await.unwrap();
|
||||
let names: Vec<_> = headers
|
||||
.iter()
|
||||
.map(|(n, _)| n.as_str().to_string())
|
||||
.collect();
|
||||
assert!(names.contains(&"authorization".to_string()));
|
||||
assert!(names.contains(&"chatgpt-account-id".to_string()));
|
||||
assert!(!names.contains(&"x-openai-fedramp".to_string()));
|
||||
|
||||
let acc = headers
|
||||
.iter()
|
||||
.find(|(n, _)| n.as_str() == "chatgpt-account-id")
|
||||
.unwrap();
|
||||
assert_eq!(acc.1.to_str().unwrap(), "acc-xyz");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fedramp_header_added_when_claim_set() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_auth(dir.path(), Utc::now().timestamp() + 3600, true, "rt");
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
|
||||
let headers = provider.headers().await.unwrap();
|
||||
let fedramp = headers
|
||||
.iter()
|
||||
.find(|(n, _)| n.as_str() == "x-openai-fedramp");
|
||||
assert!(fedramp.is_some());
|
||||
assert_eq!(fedramp.unwrap().1, "true");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshes_when_expired_and_persists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = write_auth(
|
||||
dir.path(),
|
||||
Utc::now().timestamp() - 60,
|
||||
false,
|
||||
"old-refresh",
|
||||
);
|
||||
|
||||
// refresh エンドポイントを mock。新しい JWT (将来 exp) を返す
|
||||
let server = MockServer::start().await;
|
||||
let new_access = make_jwt(json!({ "exp": Utc::now().timestamp() + 3600 }));
|
||||
let new_refresh = "new-refresh-token";
|
||||
Mock::given(method("POST"))
|
||||
.and(url_path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf())
|
||||
.with_refresh_endpoint(format!("{}/oauth/token", server.uri()))
|
||||
.with_http_client(reqwest::Client::new());
|
||||
|
||||
let headers = provider.headers().await.unwrap();
|
||||
let auth = headers
|
||||
.iter()
|
||||
.find(|(n, _)| n.as_str() == "authorization")
|
||||
.unwrap();
|
||||
assert_eq!(auth.1.to_str().unwrap(), format!("Bearer {new_access}"));
|
||||
|
||||
// ファイルに新しい refresh_token が書き戻されている
|
||||
let on_disk: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
on_disk["tokens"]["refresh_token"].as_str(),
|
||||
Some(new_refresh)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permanent_refresh_failure_surfaces_login_message() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_auth(
|
||||
dir.path(),
|
||||
Utc::now().timestamp() - 60,
|
||||
false,
|
||||
"bad-refresh",
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(url_path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "code": "refresh_token_expired" }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf())
|
||||
.with_refresh_endpoint(format!("{}/oauth/token", server.uri()))
|
||||
.with_http_client(reqwest::Client::new());
|
||||
|
||||
let err = provider.headers().await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("codex login"), "expected hint, got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_auth_json_reports_not_logged_in() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
let err = provider.headers().await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("not logged in"), "got: {msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! ChatGPT OAuth トークンの refresh HTTP 呼出。
|
||||
//!
|
||||
//! Codex CLI と同じ `POST https://auth.openai.com/oauth/token` 形式。
|
||||
//! 401 + `error.code` で永続失敗を分類する。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::error::{CodexAuthError, PermanentReason};
|
||||
|
||||
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
|
||||
pub const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
client_id: &'static str,
|
||||
grant_type: &'static str,
|
||||
refresh_token: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct RefreshResponse {
|
||||
#[serde(default)]
|
||||
pub id_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
/// refresh_token を使って新しいトークン群を取得する。
|
||||
///
|
||||
/// 永続失敗(401 + `refresh_token_(expired|reused|invalidated)`)は
|
||||
/// `RefreshPermanent`、それ以外は `RefreshTransient`。
|
||||
pub async fn request_refresh(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let body = RefreshRequest {
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
};
|
||||
let response = response_with_timeout(
|
||||
client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send(),
|
||||
DEFAULT_REFRESH_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
response
|
||||
.json::<RefreshResponse>()
|
||||
.await
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&body);
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"{status}: {body}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
) -> Result<reqwest::Response, CodexAuthError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CodexAuthError::RefreshTransient(format!(
|
||||
"oauth_token_refresh timed out after {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))
|
||||
}
|
||||
|
||||
fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
let code = extract_error_code(body);
|
||||
let reason = match code.as_deref() {
|
||||
Some("refresh_token_expired") => PermanentReason::Expired,
|
||||
Some("refresh_token_reused") => PermanentReason::Reused,
|
||||
Some("refresh_token_invalidated") => PermanentReason::Revoked,
|
||||
_ => PermanentReason::Other,
|
||||
};
|
||||
let message = match reason {
|
||||
PermanentReason::Expired => "Your refresh token has expired".to_string(),
|
||||
PermanentReason::Reused => "Your refresh token was already used".to_string(),
|
||||
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
|
||||
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
|
||||
};
|
||||
(reason, message)
|
||||
}
|
||||
|
||||
fn extract_error_code(body: &str) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
if let Some(error) = value.get("error") {
|
||||
if let Some(obj) = error.as_object() {
|
||||
if let Some(code) = obj.get("code").and_then(|v| v.as_str()) {
|
||||
return Some(code.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(s) = error.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
value
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_response_timeout_is_transient() {
|
||||
let err = match response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("expected refresh timeout"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(
|
||||
matches!(err, CodexAuthError::RefreshTransient(message) if message.contains("timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_expired() {
|
||||
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
|
||||
let (r, _) = classify_permanent(body);
|
||||
assert_eq!(r, PermanentReason::Expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reused() {
|
||||
let body = r#"{"error":{"code":"refresh_token_reused"}}"#;
|
||||
let (r, _) = classify_permanent(body);
|
||||
assert_eq!(r, PermanentReason::Reused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_falls_to_other() {
|
||||
let body = r#"{"error":{"code":"weird"}}"#;
|
||||
let (r, _) = classify_permanent(body);
|
||||
assert_eq!(r, PermanentReason::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_top_level_code() {
|
||||
let body = r#"{"code":"refresh_token_invalidated"}"#;
|
||||
let (r, _) = classify_permanent(body);
|
||||
assert_eq!(r, PermanentReason::Revoked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Optional built-in provider/backend helpers.
|
||||
|
||||
#[cfg(feature = "codex")]
|
||||
pub mod codex;
|
||||
@@ -0,0 +1,490 @@
|
||||
//! Prune — context projection for old tool-result content.
|
||||
//!
|
||||
//! LLM 送信時のコンテキストから古い [`Item::ToolResult`] の `content` を
|
||||
//! 省略して、コンテキスト窓のトークンを回収する。`summary` は残すので
|
||||
//! 「何が起きたか」の痕跡は保たれる。
|
||||
//!
|
||||
//! # 設計方針
|
||||
//!
|
||||
//! Prune は **コンテキスト射影** であり、history の変換ではない。
|
||||
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
|
||||
//! 射影の適用は LLM に送る一時コンテキストに対してだけ行う。
|
||||
//! Engine の永続履歴は決して変更されない。
|
||||
//!
|
||||
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
|
||||
//! 所有しない。prefix ごとの token 推定値と savings 推定は上位層から
|
||||
//! callback で注入される。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::llm_client::types::Item;
|
||||
use crate::token_counter::{EstimateSource, TokenEstimate};
|
||||
|
||||
/// Callback that returns token estimates for every prefix boundary of the
|
||||
/// supplied request history.
|
||||
///
|
||||
/// The returned slice must have `history.len() + 1` entries where entry `i`
|
||||
/// estimates the token count of `history[..i]`. Returning a malformed vector,
|
||||
/// or estimates whose source is [`EstimateSource::NoData`], makes prune treat
|
||||
/// the request as having no candidates.
|
||||
pub type TokenEstimator = Box<dyn Fn(&[Item]) -> Vec<TokenEstimate> + Send + Sync>;
|
||||
|
||||
/// Callback that estimates the token savings for projecting the
|
||||
/// `ToolResult.content` out of `history[i]` for each `i` in `indices`.
|
||||
///
|
||||
/// Injected into [`crate::Engine`] via `set_savings_estimator` so the
|
||||
/// Engine can make `min_savings` decisions without knowing about usage
|
||||
/// measurement sources. Return `0` to signal "no data / refuse to prune".
|
||||
///
|
||||
/// 推定対象は「drop する範囲全体」ではなく「content を None にする差分」
|
||||
/// であることに注意。item 自体(summary 等)は残るので、この callback は
|
||||
/// 実際の projection と一致する savings を返す必要がある。
|
||||
pub type SavingsEstimator = Box<dyn Fn(&[Item], &[usize]) -> u64 + Send + Sync>;
|
||||
|
||||
/// Result of one prune evaluation pass, surfaced to the optional
|
||||
/// [`PruneObserver`] for instrumentation.
|
||||
///
|
||||
/// Engine は LLM リクエストごとに 1 回 prune の評価をし、その結果を
|
||||
/// (observer が登録されていれば)この値で通知する。fire/skip の判定
|
||||
/// 結果と、判定材料になった候補数 / 推定 savings / 保護領域の先頭 index を持つ。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PruneEvaluation {
|
||||
/// `prunable_indices` の長さ。`Skipped::NoCandidates` の時は 0。
|
||||
pub candidate_count: usize,
|
||||
/// 推定された savings (tokens)。`NoCandidates` の時は 0。
|
||||
pub estimated_savings: u64,
|
||||
/// Token budget で保護される suffix の先頭 item index。
|
||||
/// usage 推定が `NoData` で境界が決まらない場合は `None`。
|
||||
pub protected_start_index: Option<usize>,
|
||||
/// 判定結果。
|
||||
pub decision: PruneDecision,
|
||||
}
|
||||
|
||||
/// Outcome of one prune evaluation. Each variant is one branch of the
|
||||
/// "fire vs skip" decision tree the Engine walks before each LLM request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PruneDecision {
|
||||
/// `prunable_indices` が空 → 何もしない。
|
||||
SkippedNoCandidates,
|
||||
/// 候補はあったが推定 savings が `min_savings` 未満 → 何もしない。
|
||||
SkippedBelowMinSavings,
|
||||
/// 候補があり savings >= min_savings → projection を適用した。
|
||||
/// `pruned_count` は `project()` が実際に書き換えた item 数
|
||||
/// (既に content=None だった候補は 0 計上)。
|
||||
Fired { pruned_count: usize },
|
||||
}
|
||||
|
||||
/// Optional observer invoked after each prune evaluation, regardless of
|
||||
/// branch. Callers can install this to publish metrics or traces.
|
||||
pub type PruneObserver = Box<dyn Fn(&PruneEvaluation) + Send + Sync>;
|
||||
|
||||
/// Configuration for the Prune algorithm.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PruneConfig {
|
||||
/// Token budget at the history tail protected from pruning.
|
||||
#[serde(default = "default_protected_tokens")]
|
||||
pub protected_tokens: u64,
|
||||
|
||||
/// Minimum token savings required to actually prune. If the prunable
|
||||
/// content is smaller than this, the caller should skip to avoid
|
||||
/// pointless KV-cache invalidation. The unit is tokens; the caller
|
||||
/// is responsible for measuring savings via a usage-history-aware
|
||||
/// estimator and comparing against this threshold.
|
||||
#[serde(default = "default_min_savings")]
|
||||
pub min_savings: u64,
|
||||
}
|
||||
|
||||
fn default_protected_tokens() -> u64 {
|
||||
8000
|
||||
}
|
||||
fn default_min_savings() -> u64 {
|
||||
4096
|
||||
}
|
||||
|
||||
impl Default for PruneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
protected_tokens: default_protected_tokens(),
|
||||
min_savings: default_min_savings(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove detailed text and attachments from each `Item::ToolResult` at the given indices.
|
||||
///
|
||||
/// The mandatory summary remains. Returns the number of items that were actually
|
||||
/// modified — results that already contain no detail are counted as 0. Intended
|
||||
/// for use on a request-context clone (never on a persistent history).
|
||||
pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
|
||||
let mut count = 0;
|
||||
for &i in indices {
|
||||
if let Item::ToolResult {
|
||||
content,
|
||||
attachments,
|
||||
..
|
||||
} = &mut items[i]
|
||||
&& (content.is_some() || !attachments.is_empty())
|
||||
{
|
||||
*content = None;
|
||||
attachments.clear();
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Indices of detailed `Item::ToolResult` values that lie before
|
||||
/// the suffix protected by `protected_tokens`. Pure: does not mutate `items`.
|
||||
///
|
||||
/// Returns an empty vector when token estimates are unavailable (`NoData`) or
|
||||
/// no prunable candidates exist.
|
||||
pub fn prunable_indices(
|
||||
items: &[Item],
|
||||
protected_tokens: u64,
|
||||
token_estimates: &[TokenEstimate],
|
||||
) -> Vec<usize> {
|
||||
evaluate_candidates(items, protected_tokens, token_estimates).0
|
||||
}
|
||||
|
||||
/// Same as [`prunable_indices`] but also returns the start index of the
|
||||
/// protected suffix. `None` means the token boundary could not be determined
|
||||
/// (currently because usage estimates were `NoData` or malformed).
|
||||
pub fn evaluate_candidates(
|
||||
items: &[Item],
|
||||
protected_tokens: u64,
|
||||
token_estimates: &[TokenEstimate],
|
||||
) -> (Vec<usize>, Option<usize>) {
|
||||
let Some(protected_start) = protected_start_index(items, protected_tokens, token_estimates)
|
||||
else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
|
||||
let candidates = items[..protected_start]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, item)| match item {
|
||||
Item::ToolResult {
|
||||
content,
|
||||
attachments,
|
||||
..
|
||||
} if content.is_some() || !attachments.is_empty() => Some(i),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(candidates, Some(protected_start))
|
||||
}
|
||||
|
||||
fn protected_start_index(
|
||||
items: &[Item],
|
||||
protected_tokens: u64,
|
||||
token_estimates: &[TokenEstimate],
|
||||
) -> Option<usize> {
|
||||
if token_estimates.len() != items.len() + 1 {
|
||||
return None;
|
||||
}
|
||||
let total = token_estimates[items.len()];
|
||||
if total.source == EstimateSource::NoData {
|
||||
return None;
|
||||
}
|
||||
if protected_tokens == 0 {
|
||||
return Some(items.len());
|
||||
}
|
||||
|
||||
let mut protected_start = items.len();
|
||||
for idx in (0..items.len()).rev() {
|
||||
let prefix = token_estimates[idx];
|
||||
if prefix.source == EstimateSource::NoData {
|
||||
return None;
|
||||
}
|
||||
protected_start = idx;
|
||||
let tail_tokens = total.tokens.saturating_sub(prefix.tokens);
|
||||
if tail_tokens >= protected_tokens {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(protected_start)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: build a history with interleaved user messages and tool results.
|
||||
fn make_history(turns: &[(&str, Vec<(&str, Option<&str>)>)]) -> Vec<Item> {
|
||||
let mut items = Vec::new();
|
||||
for (user_msg, tool_results) in turns {
|
||||
items.push(Item::user_message(*user_msg));
|
||||
items.push(Item::assistant_message("ok"));
|
||||
for (i, (summary, content)) in tool_results.iter().enumerate() {
|
||||
let call_id = format!("call_{}", items.len() + i);
|
||||
items.push(Item::tool_call(&call_id, "some_tool", "{}"));
|
||||
match content {
|
||||
Some(c) => items.push(Item::tool_result_with_content(&call_id, *summary, *c)),
|
||||
None => items.push(Item::tool_result(&call_id, *summary)),
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn measured_prefix(tokens: &[u64]) -> Vec<TokenEstimate> {
|
||||
tokens
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|tokens| TokenEstimate {
|
||||
tokens,
|
||||
source: EstimateSource::Measured,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn uniform_estimates(items: &[Item], item_tokens: u64) -> Vec<TokenEstimate> {
|
||||
let mut tokens = Vec::with_capacity(items.len() + 1);
|
||||
for i in 0..=items.len() {
|
||||
tokens.push(i as u64 * item_tokens);
|
||||
}
|
||||
measured_prefix(&tokens)
|
||||
}
|
||||
|
||||
fn estimates_from_item_tokens(item_tokens: &[u64]) -> Vec<TokenEstimate> {
|
||||
let mut prefix = Vec::with_capacity(item_tokens.len() + 1);
|
||||
let mut acc = 0;
|
||||
prefix.push(acc);
|
||||
for tokens in item_tokens {
|
||||
acc += tokens;
|
||||
prefix.push(acc);
|
||||
}
|
||||
measured_prefix(&prefix)
|
||||
}
|
||||
|
||||
fn no_data_estimates(items: &[Item]) -> Vec<TokenEstimate> {
|
||||
(0..=items.len())
|
||||
.map(|i| TokenEstimate {
|
||||
tokens: i as u64,
|
||||
source: if i == 0 {
|
||||
EstimateSource::Measured
|
||||
} else {
|
||||
EstimateSource::NoData
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_candidates_when_estimate_has_no_data() {
|
||||
let items = make_history(&[("turn1", vec![("summary1", Some("big content here"))])]);
|
||||
let estimates = no_data_estimates(&items);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 10, &estimates);
|
||||
assert!(candidates.is_empty());
|
||||
assert_eq!(protected_start, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_candidates_when_history_fits_in_protected_tokens() {
|
||||
let items = make_history(&[
|
||||
("turn1", vec![("summary1", Some("big content here"))]),
|
||||
("turn2", vec![("summary2", Some("more content"))]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
assert!(prunable_indices(&items, 10_000, &estimates).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_before_token_protected_suffix() {
|
||||
let big = "x".repeat(4096 * 4);
|
||||
let items = make_history(&[
|
||||
("turn1", vec![("s1", Some(&big))]),
|
||||
("turn2", vec![("s2", Some(&big))]),
|
||||
("turn3", vec![("s3", Some("keep me"))]),
|
||||
("turn4", vec![("s4", Some("keep me too"))]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let candidates = prunable_indices(&items, 80, &estimates);
|
||||
assert_eq!(candidates.len(), 2);
|
||||
// suffix budget 80 tokens protects turn3+turn4 (8 items), so only s1/s2 are candidates.
|
||||
for &i in &candidates {
|
||||
if let Item::ToolResult { summary, .. } = &items[i] {
|
||||
assert!(summary == "s1" || summary == "s2");
|
||||
} else {
|
||||
panic!("non tool-result selected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_long_task_gets_candidates_without_multiple_user_turns() {
|
||||
let big = "x".repeat(4096 * 8);
|
||||
let items = make_history(&[(
|
||||
"one long task",
|
||||
vec![
|
||||
("s1", Some(&big)),
|
||||
("s2", Some(&big)),
|
||||
("s3", Some(&big)),
|
||||
("s4", Some(&big)),
|
||||
],
|
||||
)]);
|
||||
// user + assistant are cheap; every ToolCall is cheap; every ToolResult is heavy.
|
||||
let item_tokens = vec![1, 1, 1, 5_000, 1, 5_000, 1, 5_000, 1, 5_000];
|
||||
let estimates = estimates_from_item_tokens(&item_tokens);
|
||||
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 8_000, &estimates);
|
||||
|
||||
assert_eq!(protected_start, Some(7));
|
||||
assert_eq!(candidates.len(), 2);
|
||||
for &i in &candidates {
|
||||
if let Item::ToolResult { summary, .. } = &items[i] {
|
||||
assert!(summary == "s1" || summary == "s2");
|
||||
} else {
|
||||
panic!("non tool-result selected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_pruned_items_excluded_from_candidates() {
|
||||
let items = make_history(&[
|
||||
("turn1", vec![("s1", None)]), // already pruned (content=None)
|
||||
("turn2", vec![]),
|
||||
("turn3", vec![]),
|
||||
("turn4", vec![]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
assert!(prunable_indices(&items, 20, &estimates).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_drops_content_and_counts_modifications() {
|
||||
let big = "x".repeat(64);
|
||||
let mut items = make_history(&[
|
||||
("turn1", vec![("s1", Some(&big))]),
|
||||
("turn2", vec![("s2", Some(&big))]),
|
||||
("turn3", vec![("s3", Some("keep me"))]),
|
||||
("turn4", vec![("s4", Some("keep me too"))]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let candidates = prunable_indices(&items, 80, &estimates);
|
||||
let count = project(&mut items, &candidates);
|
||||
assert_eq!(count, 2);
|
||||
|
||||
for item in &items {
|
||||
if let Item::ToolResult {
|
||||
summary, content, ..
|
||||
} = item
|
||||
{
|
||||
if summary == "s1" || summary == "s2" {
|
||||
assert!(content.is_none(), "old content should be projected out");
|
||||
} else {
|
||||
assert!(content.is_some(), "protected content should remain");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_drops_image_detail_but_keeps_summary_and_persistent_source() {
|
||||
let original = vec![Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"Attached image/png image (12 bytes)",
|
||||
None,
|
||||
false,
|
||||
vec![crate::tool::Attachment::Image(
|
||||
crate::tool::ImageAttachment::new("image/png", b"image-body".to_vec()),
|
||||
)],
|
||||
)];
|
||||
let mut request_context = original.clone();
|
||||
|
||||
let estimates = uniform_estimates(&original, 100);
|
||||
assert_eq!(prunable_indices(&original, 0, &estimates), vec![0]);
|
||||
|
||||
assert_eq!(project(&mut request_context, &[0]), 1);
|
||||
assert!(matches!(
|
||||
&request_context[0],
|
||||
Item::ToolResult {
|
||||
summary,
|
||||
content: None,
|
||||
attachments,
|
||||
..
|
||||
} if summary == "Attached image/png image (12 bytes)" && attachments.is_empty()
|
||||
));
|
||||
assert!(matches!(
|
||||
&original[0],
|
||||
Item::ToolResult { attachments, .. } if attachments.len() == 1
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skips_already_pruned_items() {
|
||||
// indices points at an item whose content is already None.
|
||||
// project() should count it as 0 modifications.
|
||||
let mut items = make_history(&[
|
||||
("turn1", vec![("s1", None)]),
|
||||
("turn2", vec![("s2", Some("hello"))]),
|
||||
]);
|
||||
// Manually target s1 even though it's already None.
|
||||
let target = items
|
||||
.iter()
|
||||
.position(|it| matches!(it, Item::ToolResult { summary, .. } if summary == "s1"))
|
||||
.unwrap();
|
||||
let count = project(&mut items, &[target]);
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_is_idempotent() {
|
||||
let big = "x".repeat(64);
|
||||
let mut items = make_history(&[
|
||||
("turn1", vec![("s1", Some(&big))]),
|
||||
("turn2", vec![]),
|
||||
("turn3", vec![]),
|
||||
("turn4", vec![]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let candidates = prunable_indices(&items, 20, &estimates);
|
||||
assert_eq!(project(&mut items, &candidates), 1);
|
||||
// 2 周目: 候補は一度の prunable_indices 結果を使い回しても 0 件。
|
||||
assert_eq!(project(&mut items, &candidates), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_candidates_returns_protected_start_index() {
|
||||
let big = "x".repeat(64);
|
||||
let items = make_history(&[
|
||||
("turn1", vec![("s1", Some(&big))]),
|
||||
("turn2", vec![("s2", Some(&big))]),
|
||||
("turn3", vec![("s3", Some("keep"))]),
|
||||
("turn4", vec![("s4", Some("keep too"))]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 80, &estimates);
|
||||
assert_eq!(candidates.len(), 2);
|
||||
// protected_tokens=80 → protected suffix is turn3+turn4, starting at index 8.
|
||||
assert_eq!(protected_start, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_candidates_reports_zero_start_when_everything_is_protected() {
|
||||
let items = make_history(&[("only", vec![("s", Some("x"))])]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 10_000, &estimates);
|
||||
assert!(candidates.is_empty());
|
||||
assert_eq!(protected_start, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_protected_tokens_allows_all_tool_results_as_candidates() {
|
||||
let big = "x".repeat(64);
|
||||
let items = make_history(&[("turn1", vec![("s1", Some(&big)), ("s2", Some(&big))])]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 0, &estimates);
|
||||
assert_eq!(protected_start, Some(items.len()));
|
||||
assert_eq!(candidates.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_estimate_vector_is_treated_as_no_boundary() {
|
||||
let items = make_history(&[("turn1", vec![("s1", Some("x"))])]);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 10, &[]);
|
||||
assert!(candidates.is_empty());
|
||||
assert_eq!(protected_start, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Engine State
|
||||
//!
|
||||
//! State marker types for cache protection using the Type-state pattern.
|
||||
//! Engine has state transitions from `Mutable` → `Locked`.
|
||||
|
||||
/// Marker trait representing Engine state
|
||||
///
|
||||
/// This trait is sealed and cannot be implemented externally.
|
||||
pub trait EngineState: private::Sealed + Send + Sync + 'static {}
|
||||
|
||||
mod private {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
/// Mutable state (editable)
|
||||
///
|
||||
/// In this state, the following operations are available:
|
||||
/// - Setting/changing system prompt
|
||||
/// - Editing message history (add, delete, clear)
|
||||
/// - Registering tools and hooks
|
||||
///
|
||||
/// Can transition to [`Locked`] state via `Engine::lock()`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use agen::Engine;
|
||||
///
|
||||
/// let mut engine = Engine::new(client)
|
||||
/// .system_prompt("You are helpful.");
|
||||
///
|
||||
/// // History can be edited
|
||||
/// engine.push_message(Message::user("Hello"));
|
||||
/// engine.clear_history();
|
||||
///
|
||||
/// // Lock to protected state
|
||||
/// let locked = engine.lock();
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Mutable;
|
||||
|
||||
impl private::Sealed for Mutable {}
|
||||
impl EngineState for Mutable {}
|
||||
|
||||
/// Cache locked state (cache protected)
|
||||
///
|
||||
/// In this state, the following restrictions apply:
|
||||
/// - System prompt cannot be changed
|
||||
/// - Existing message history cannot be modified (only appending to the end)
|
||||
///
|
||||
/// To ensure LLM API KV cache hits,
|
||||
/// using this state during execution is recommended.
|
||||
///
|
||||
/// Can return to [`Mutable`] state via `Engine::unlock()`,
|
||||
/// but note that cache protection will be released.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Locked;
|
||||
|
||||
impl private::Sealed for Locked {}
|
||||
impl EngineState for Locked {}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Timeline層のイベント型
|
||||
//!
|
||||
//! llm_client層のイベント型をそのまま使用する。
|
||||
|
||||
pub use crate::llm_client::event::*;
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Timeline層
|
||||
//!
|
||||
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
|
||||
//!
|
||||
//! # 主要コンポーネント
|
||||
//!
|
||||
//! - [`Timeline`] - イベントストリームの管理とディスパッチ
|
||||
//! - [`Handler`] - イベントを処理するトレイト
|
||||
//! - [`TextBlockCollector`] - テキストブロックを収集するHandler
|
||||
//! - [`ThinkingBlockCollector`] - reasoning material を Thinking block から収集するHandler
|
||||
//! - [`ToolCallCollector`] - ツール呼び出しを収集するHandler
|
||||
|
||||
pub mod event;
|
||||
mod text_block_collector;
|
||||
mod thinking_block_collector;
|
||||
mod timeline;
|
||||
mod tool_call_collector;
|
||||
|
||||
// 公開API
|
||||
pub use event::*;
|
||||
pub use text_block_collector::TextBlockCollector;
|
||||
pub use thinking_block_collector::ThinkingBlockCollector;
|
||||
pub use timeline::Timeline;
|
||||
pub use tool_call_collector::ToolCallCollector;
|
||||
|
||||
// 型定義からのre-export
|
||||
pub use crate::handler::{
|
||||
// Meta Kinds
|
||||
ErrorKind,
|
||||
// Core traits
|
||||
Handler,
|
||||
Kind,
|
||||
PingKind,
|
||||
StatusKind,
|
||||
// Block Events
|
||||
TextBlockEvent,
|
||||
// Block Kinds
|
||||
TextBlockKind,
|
||||
TextBlockStart,
|
||||
TextBlockStop,
|
||||
ThinkingBlockEvent,
|
||||
ThinkingBlockKind,
|
||||
ThinkingBlockStart,
|
||||
ThinkingBlockStop,
|
||||
ToolUseBlockEvent,
|
||||
ToolUseBlockKind,
|
||||
ToolUseBlockStart,
|
||||
ToolUseBlockStop,
|
||||
UsageKind,
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
//! TextBlockCollector - テキストブロック収集用ハンドラ
|
||||
//!
|
||||
//! TimelineのTextBlockHandler として登録され、
|
||||
//! ストリーム中のテキストブロックを収集する。
|
||||
|
||||
use crate::handler::{Handler, TextBlockEvent, TextBlockKind};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// TextBlockから収集したテキスト情報を保持
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TextCollectorState {
|
||||
/// 蓄積中のテキスト
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
/// TextBlockCollector - テキストブロックハンドラ
|
||||
///
|
||||
/// Timelineに登録してTextBlockイベントを受信し、
|
||||
/// 完了したテキストブロックを収集する。
|
||||
#[derive(Clone)]
|
||||
pub struct TextBlockCollector {
|
||||
/// 収集されたテキストブロック
|
||||
collected: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl TextBlockCollector {
|
||||
/// 新しいTextBlockCollectorを作成
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
collected: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 収集されたテキストを取得してクリア
|
||||
pub fn take_collected(&self) -> Vec<String> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集されたテキストの参照を取得
|
||||
pub fn collected(&self) -> Vec<String> {
|
||||
self.collected.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// 収集されたテキストがあるかどうか
|
||||
pub fn has_content(&self) -> bool {
|
||||
!self.collected.lock().unwrap().is_empty()
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TextBlockCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for TextBlockCollector {
|
||||
type Scope = TextCollectorState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &TextBlockEvent) {
|
||||
match event {
|
||||
TextBlockEvent::Start(_) => {
|
||||
scope.buffer.clear();
|
||||
}
|
||||
TextBlockEvent::Delta(text) => {
|
||||
scope.buffer.push_str(text);
|
||||
}
|
||||
TextBlockEvent::Stop(_) => {
|
||||
// ブロック完了時にテキストを確定
|
||||
if !scope.buffer.is_empty() {
|
||||
let text = std::mem::take(&mut scope.buffer);
|
||||
self.collected.lock().unwrap().push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::timeline::Timeline;
|
||||
use crate::timeline::event::Event;
|
||||
|
||||
/// TextBlockCollectorが単一のテキストブロックを正しく収集することを確認
|
||||
#[test]
|
||||
fn test_collect_single_text_block() {
|
||||
let collector = TextBlockCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(collector.clone());
|
||||
|
||||
// テキストブロックのイベントシーケンスをディスパッチ
|
||||
timeline.dispatch(&Event::text_block_start(0));
|
||||
timeline.dispatch(&Event::text_delta(0, "Hello, "));
|
||||
timeline.dispatch(&Event::text_delta(0, "World!"));
|
||||
timeline.dispatch(&Event::text_block_stop(0, None));
|
||||
|
||||
// 収集されたテキストを確認
|
||||
let texts = collector.take_collected();
|
||||
assert_eq!(texts.len(), 1);
|
||||
assert_eq!(texts[0], "Hello, World!");
|
||||
}
|
||||
|
||||
/// TextBlockCollectorが複数のテキストブロックを正しく収集することを確認
|
||||
#[test]
|
||||
fn test_collect_multiple_text_blocks() {
|
||||
let collector = TextBlockCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(collector.clone());
|
||||
|
||||
// 1つ目のテキストブロック
|
||||
timeline.dispatch(&Event::text_block_start(0));
|
||||
timeline.dispatch(&Event::text_delta(0, "First"));
|
||||
timeline.dispatch(&Event::text_block_stop(0, None));
|
||||
|
||||
// 2つ目のテキストブロック
|
||||
timeline.dispatch(&Event::text_block_start(1));
|
||||
timeline.dispatch(&Event::text_delta(1, "Second"));
|
||||
timeline.dispatch(&Event::text_block_stop(1, None));
|
||||
|
||||
let texts = collector.take_collected();
|
||||
assert_eq!(texts.len(), 2);
|
||||
assert_eq!(texts[0], "First");
|
||||
assert_eq!(texts[1], "Second");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! `ThinkingBlockCollector` - reasoning material from Thinking block lifecycle.
|
||||
//!
|
||||
//! Scheme implementations emit Thinking BlockStart/Delta/Stop events for live
|
||||
//! streaming. A Thinking block stop with `ReasoningBlockData` is also the single
|
||||
//! authoritative persistence signal for `Item::Reasoning` round-trip material.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::handler::{Handler, ThinkingBlockEvent, ThinkingBlockKind};
|
||||
use crate::llm_client::event::ReasoningBlockData;
|
||||
|
||||
/// Reasoning material collected from completed Thinking blocks.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ThinkingBlockCollector {
|
||||
collected: Arc<Mutex<Vec<ReasoningBlockData>>>,
|
||||
}
|
||||
|
||||
impl ThinkingBlockCollector {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 収集済み item を取り出してクリア
|
||||
pub fn take_collected(&self) -> Vec<ReasoningBlockData> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<ThinkingBlockKind> for ThinkingBlockCollector {
|
||||
type Scope = String;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ThinkingBlockEvent) {
|
||||
match event {
|
||||
ThinkingBlockEvent::Start(_) => scope.clear(),
|
||||
ThinkingBlockEvent::Delta(text) => scope.push_str(text),
|
||||
ThinkingBlockEvent::Stop(stop) => {
|
||||
if let Some(mut reasoning) = stop.reasoning.clone() {
|
||||
if reasoning.text.is_none() {
|
||||
reasoning.text = Some(scope.clone());
|
||||
}
|
||||
self.collected.lock().unwrap().push(reasoning);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::event::{
|
||||
BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, Event, ReasoningBlockData,
|
||||
};
|
||||
use crate::timeline::Timeline;
|
||||
|
||||
#[test]
|
||||
fn collects_in_order_from_thinking_block_stops() {
|
||||
let collector = ThinkingBlockCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_thinking_block(collector.clone());
|
||||
|
||||
timeline.dispatch(&Event::BlockStart(BlockStart {
|
||||
index: 0,
|
||||
block_type: BlockType::Thinking,
|
||||
metadata: BlockMetadata::Thinking,
|
||||
}));
|
||||
timeline.dispatch(&Event::BlockDelta(crate::llm_client::event::BlockDelta {
|
||||
index: 0,
|
||||
delta: DeltaContent::Thinking("first".into()),
|
||||
}));
|
||||
timeline.dispatch(&Event::BlockStop(BlockStop {
|
||||
index: 0,
|
||||
block_type: BlockType::Thinking,
|
||||
stop_reason: None,
|
||||
reasoning: Some(ReasoningBlockData {
|
||||
id: Some("r1".into()),
|
||||
signature: Some("sig1".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
}));
|
||||
|
||||
timeline.dispatch(&Event::BlockStart(BlockStart {
|
||||
index: 1,
|
||||
block_type: BlockType::Thinking,
|
||||
metadata: BlockMetadata::Thinking,
|
||||
}));
|
||||
timeline.dispatch(&Event::BlockStop(BlockStop {
|
||||
index: 1,
|
||||
block_type: BlockType::Thinking,
|
||||
stop_reason: None,
|
||||
reasoning: Some(ReasoningBlockData {
|
||||
id: Some("r2".into()),
|
||||
text: Some("second".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
}));
|
||||
|
||||
let items = collector.take_collected();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].text.as_deref(), Some("first"));
|
||||
assert_eq!(items[0].signature.as_deref(), Some("sig1"));
|
||||
assert_eq!(items[1].text.as_deref(), Some("second"));
|
||||
|
||||
// take は drain なので 2 度目は空
|
||||
assert!(collector.take_collected().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_streaming_only_thinking_blocks() {
|
||||
let collector = ThinkingBlockCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_thinking_block(collector.clone());
|
||||
|
||||
timeline.dispatch(&Event::BlockStart(BlockStart {
|
||||
index: 0,
|
||||
block_type: BlockType::Thinking,
|
||||
metadata: BlockMetadata::Thinking,
|
||||
}));
|
||||
timeline.dispatch(&Event::BlockDelta(crate::llm_client::event::BlockDelta {
|
||||
index: 0,
|
||||
delta: DeltaContent::Thinking("live only".into()),
|
||||
}));
|
||||
timeline.dispatch(&Event::BlockStop(BlockStop {
|
||||
index: 0,
|
||||
block_type: BlockType::Thinking,
|
||||
stop_reason: None,
|
||||
reasoning: None,
|
||||
}));
|
||||
|
||||
assert!(collector.take_collected().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
//! Timeline層
|
||||
//!
|
||||
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
|
||||
//! 通常はEngine経由で使用しますが、直接使用することも可能です。
|
||||
|
||||
use std::{collections::HashMap, marker::PhantomData};
|
||||
|
||||
use super::event::*;
|
||||
use crate::handler::*;
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/// 1リクエスト内で受信した複数 UsageEvent をマージする。
|
||||
/// 各フィールドについて新しい値が `Some` ならそれで上書き。
|
||||
/// プロバイダによっては input/cache 系を最初の event だけに載せ、
|
||||
/// output_tokens を後続 event で更新するため、最後の値だけを取るのではなく
|
||||
/// フィールド単位で latest-non-None を取る。
|
||||
fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
|
||||
if new.input_tokens.is_some() {
|
||||
acc.input_tokens = new.input_tokens;
|
||||
}
|
||||
if new.output_tokens.is_some() {
|
||||
acc.output_tokens = new.output_tokens;
|
||||
}
|
||||
if new.total_tokens.is_some() {
|
||||
acc.total_tokens = new.total_tokens;
|
||||
}
|
||||
if new.cache_read_input_tokens.is_some() {
|
||||
acc.cache_read_input_tokens = new.cache_read_input_tokens;
|
||||
}
|
||||
if new.cache_creation_input_tokens.is_some() {
|
||||
acc.cache_creation_input_tokens = new.cache_creation_input_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type-erased Handler
|
||||
// =============================================================================
|
||||
|
||||
/// 型消去された`Handler` trait
|
||||
///
|
||||
/// 各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要です。
|
||||
/// 通常は直接使用せず、`Timeline::on_text_block()`などのメソッド経由で
|
||||
/// 自動的にラップされます。
|
||||
pub trait ErasedHandler<K: Kind>: Send + Sync {
|
||||
/// イベントをディスパッチ
|
||||
fn dispatch(&mut self, event: &K::Event);
|
||||
/// スコープを開始
|
||||
fn start_scope(&mut self);
|
||||
}
|
||||
|
||||
/// `Handler<K>`を`ErasedHandler<K>`として扱うためのラッパー
|
||||
pub struct HandlerWrapper<H, K>
|
||||
where
|
||||
H: Handler<K>,
|
||||
K: Kind,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
// fn() -> K は常にSend+Syncなので、Kの制約に関係なくSendを満たせる
|
||||
_kind: PhantomData<fn() -> K>,
|
||||
}
|
||||
|
||||
impl<H, K> HandlerWrapper<H, K>
|
||||
where
|
||||
H: Handler<K>,
|
||||
K: Kind,
|
||||
{
|
||||
pub fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
_kind: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H, K> ErasedHandler<K> for HandlerWrapper<H, K>
|
||||
where
|
||||
H: Handler<K> + Send + Sync,
|
||||
K: Kind,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch(&mut self, event: &K::Event) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
self.handler.on_event(scope, event);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Block Handler Registry
|
||||
// =============================================================================
|
||||
|
||||
/// ブロックハンドラーの型消去trait
|
||||
trait ErasedBlockHandler: Send + Sync {
|
||||
fn dispatch_start(&mut self, start: &BlockStart);
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta);
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop);
|
||||
fn dispatch_abort(&mut self, abort: &BlockAbort);
|
||||
fn start_scope(&mut self);
|
||||
fn end_scope(&mut self);
|
||||
/// スコープがアクティブかどうか
|
||||
fn has_scope(&self) -> bool;
|
||||
}
|
||||
|
||||
/// TextBlockKind用のラッパー
|
||||
struct TextBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<TextBlockKind>,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
}
|
||||
|
||||
impl<H> TextBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<TextBlockKind>,
|
||||
{
|
||||
fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> ErasedBlockHandler for TextBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<TextBlockKind> + Send + Sync,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch_start(&mut self, start: &BlockStart) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&TextBlockEvent::Start(TextBlockStart { index: start.index }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&TextBlockEvent::Stop(TextBlockStop {
|
||||
index: stop.index,
|
||||
stop_reason: stop.stop_reason.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
|
||||
// TextBlockはabortを特別扱いしない(スコープ終了のみ)
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
|
||||
fn end_scope(&mut self) {
|
||||
self.scope = None;
|
||||
}
|
||||
|
||||
fn has_scope(&self) -> bool {
|
||||
self.scope.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// ThinkingBlockKind用のラッパー
|
||||
struct ThinkingBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ThinkingBlockKind>,
|
||||
{
|
||||
handler: H,
|
||||
scopes: HashMap<usize, H::Scope>,
|
||||
}
|
||||
|
||||
impl<H> ThinkingBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ThinkingBlockKind>,
|
||||
{
|
||||
fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scopes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> ErasedBlockHandler for ThinkingBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ThinkingBlockKind> + Send + Sync,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch_start(&mut self, start: &BlockStart) {
|
||||
let scope = self.scopes.entry(start.index).or_default();
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ThinkingBlockEvent::Start(ThinkingBlockStart { index: start.index }),
|
||||
);
|
||||
}
|
||||
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta) {
|
||||
if let DeltaContent::Thinking(text) = &delta.delta {
|
||||
let scope = self.scopes.entry(delta.index).or_default();
|
||||
self.handler
|
||||
.on_event(scope, &ThinkingBlockEvent::Delta(text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop) {
|
||||
if let Some(mut scope) = self.scopes.remove(&stop.index) {
|
||||
self.handler.on_event(
|
||||
&mut scope,
|
||||
&ThinkingBlockEvent::Stop(ThinkingBlockStop {
|
||||
index: stop.index,
|
||||
reasoning: stop.reasoning.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
|
||||
self.scopes.clear();
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {}
|
||||
|
||||
fn end_scope(&mut self) {}
|
||||
|
||||
fn has_scope(&self) -> bool {
|
||||
!self.scopes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// ToolUseBlockKind用のラッパー
|
||||
struct ToolUseBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ToolUseBlockKind>,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
current_tool: Option<(String, String)>, // (id, name)
|
||||
}
|
||||
|
||||
impl<H> ToolUseBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ToolUseBlockKind>,
|
||||
{
|
||||
fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
current_tool: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> ErasedBlockHandler for ToolUseBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ToolUseBlockKind> + Send + Sync,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch_start(&mut self, start: &BlockStart) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
|
||||
self.current_tool = Some((id.clone(), name.clone()));
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ToolUseBlockEvent::Start(ToolUseBlockStart {
|
||||
index: start.index,
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let DeltaContent::InputJson(json) = &delta.delta {
|
||||
self.handler
|
||||
.on_event(scope, &ToolUseBlockEvent::InputJsonDelta(json.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let Some((id, name)) = self.current_tool.take() {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ToolUseBlockEvent::Stop(ToolUseBlockStop {
|
||||
index: stop.index,
|
||||
id,
|
||||
name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
|
||||
self.current_tool = None;
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
|
||||
fn end_scope(&mut self) {
|
||||
self.scope = None;
|
||||
self.current_tool = None;
|
||||
}
|
||||
|
||||
fn has_scope(&self) -> bool {
|
||||
self.scope.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Timeline
|
||||
// =============================================================================
|
||||
|
||||
/// イベントストリームの管理とハンドラへのディスパッチ
|
||||
///
|
||||
/// LLMからのイベントを受信し、登録されたハンドラに振り分けます。
|
||||
/// ブロック系イベントはスコープ管理付きで処理されます。
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use agen::{Timeline, Handler, TextBlockKind, TextBlockEvent};
|
||||
///
|
||||
/// struct MyHandler;
|
||||
/// impl Handler<TextBlockKind> for MyHandler {
|
||||
/// type Scope = String;
|
||||
/// fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
|
||||
/// if let TextBlockEvent::Delta(text) = event {
|
||||
/// buffer.push_str(text);
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// let mut timeline = Timeline::new();
|
||||
/// timeline.on_text_block(MyHandler);
|
||||
/// ```
|
||||
///
|
||||
/// # サポートするイベント種別
|
||||
///
|
||||
/// - **メタ系**: Usage, Ping, Status, Error
|
||||
/// - **ブロック系**: TextBlock, ThinkingBlock, ToolUseBlock
|
||||
pub struct Timeline {
|
||||
// Meta系ハンドラー
|
||||
usage_handlers: Vec<Box<dyn ErasedHandler<UsageKind>>>,
|
||||
ping_handlers: Vec<Box<dyn ErasedHandler<PingKind>>>,
|
||||
status_handlers: Vec<Box<dyn ErasedHandler<StatusKind>>>,
|
||||
error_handlers: Vec<Box<dyn ErasedHandler<ErrorKind>>>,
|
||||
// Block系ハンドラー(BlockTypeごとにグループ化)
|
||||
text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
|
||||
// 現在アクティブなブロック
|
||||
current_block: Option<BlockType>,
|
||||
|
||||
// 1リクエスト内で受信した Usage event の集約バッファ。
|
||||
// Anthropic は message_start と message_delta、Gemini は各チャンクと、
|
||||
// 多くのプロバイダが複数 Usage を発行するため、リクエスト境界で
|
||||
// 1度だけ発火するためにここでマージする。flush_usage() で発火する。
|
||||
pending_usage: Option<UsageEvent>,
|
||||
}
|
||||
|
||||
impl Default for Timeline {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
usage_handlers: Vec::new(),
|
||||
ping_handlers: Vec::new(),
|
||||
status_handlers: Vec::new(),
|
||||
error_handlers: Vec::new(),
|
||||
text_block_handlers: Vec::new(),
|
||||
thinking_block_handlers: Vec::new(),
|
||||
tool_use_block_handlers: Vec::new(),
|
||||
current_block: None,
|
||||
pending_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Handler Registration
|
||||
// =========================================================================
|
||||
|
||||
/// UsageKind用のHandlerを登録
|
||||
pub fn on_usage<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<UsageKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
// Meta系はデフォルトでスコープを開始しておく
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.usage_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// PingKind用のHandlerを登録
|
||||
pub fn on_ping<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<PingKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.ping_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// StatusKind用のHandlerを登録
|
||||
pub fn on_status<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<StatusKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.status_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// ErrorKind用のHandlerを登録
|
||||
pub fn on_error<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ErrorKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.error_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// TextBlockKind用のHandlerを登録
|
||||
pub fn on_text_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<TextBlockKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
self.text_block_handlers
|
||||
.push(Box::new(TextBlockHandlerWrapper::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// ThinkingBlockKind用のHandlerを登録
|
||||
pub fn on_thinking_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ThinkingBlockKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
self.thinking_block_handlers
|
||||
.push(Box::new(ThinkingBlockHandlerWrapper::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// ToolUseBlockKind用のHandlerを登録
|
||||
pub fn on_tool_use_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ToolUseBlockKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
self.tool_use_block_handlers
|
||||
.push(Box::new(ToolUseBlockHandlerWrapper::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Event Dispatch
|
||||
// =========================================================================
|
||||
|
||||
/// メインのディスパッチエントリポイント
|
||||
pub fn dispatch(&mut self, event: &Event) {
|
||||
match event {
|
||||
// Meta系: 即時ディスパッチ(登録順)
|
||||
Event::Usage(u) => self.dispatch_usage(u),
|
||||
Event::Ping(p) => self.dispatch_ping(p),
|
||||
Event::Status(s) => self.dispatch_status(s),
|
||||
Event::Error(e) => self.dispatch_error(e),
|
||||
// Observability-only event: stream trace records it before timeline dispatch.
|
||||
Event::UnhandledSse(_) => {}
|
||||
|
||||
// Block系: スコープ管理しながらディスパッチ
|
||||
Event::BlockStart(s) => self.handle_block_start(s),
|
||||
Event::BlockDelta(d) => self.handle_block_delta(d),
|
||||
Event::BlockStop(s) => self.handle_block_stop(s),
|
||||
Event::BlockAbort(a) => self.handle_block_abort(a),
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage event を即時には dispatch せず、pending_usage にマージする。
|
||||
/// 1リクエスト内で複数の Usage event が来ても、ハンドラには 1 度だけ
|
||||
/// 最終値を渡したいため。flush_usage() で発火する。
|
||||
fn dispatch_usage(&mut self, event: &UsageEvent) {
|
||||
match &mut self.pending_usage {
|
||||
Some(acc) => merge_usage(acc, event),
|
||||
None => self.pending_usage = Some(event.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// pending_usage を usage_handlers に発火し、バッファをクリアする。
|
||||
/// 1リクエスト分のストリーム終了時に1回だけ呼ぶ想定。
|
||||
/// pending_usage が空ならば何もしない。
|
||||
pub fn flush_usage(&mut self) {
|
||||
if let Some(event) = self.pending_usage.take() {
|
||||
for handler in &mut self.usage_handlers {
|
||||
handler.dispatch(&event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_ping(&mut self, event: &PingEvent) {
|
||||
for handler in &mut self.ping_handlers {
|
||||
handler.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_status(&mut self, event: &StatusEvent) {
|
||||
for handler in &mut self.status_handlers {
|
||||
handler.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_error(&mut self, event: &ErrorEvent) {
|
||||
for handler in &mut self.error_handlers {
|
||||
handler.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_block_start(&mut self, start: &BlockStart) {
|
||||
self.current_block = Some(start.block_type);
|
||||
|
||||
let handlers = self.get_block_handlers_mut(start.block_type);
|
||||
for handler in handlers {
|
||||
handler.start_scope();
|
||||
handler.dispatch_start(start);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_block_delta(&mut self, delta: &BlockDelta) {
|
||||
let block_type = delta.delta.block_type();
|
||||
|
||||
// OpenAIなどのプロバイダはBlockStartを送らない場合があるため、
|
||||
// Deltaが来たときにスコープがなければ暗黙的に開始する
|
||||
if self.current_block.is_none() {
|
||||
self.current_block = Some(block_type);
|
||||
}
|
||||
|
||||
let handlers = self.get_block_handlers_mut(block_type);
|
||||
for handler in handlers {
|
||||
// スコープがなければ暗黙的に開始
|
||||
if !handler.has_scope() {
|
||||
handler.start_scope();
|
||||
}
|
||||
handler.dispatch_delta(delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_block_stop(&mut self, stop: &BlockStop) {
|
||||
let handlers = self.get_block_handlers_mut(stop.block_type);
|
||||
for handler in handlers {
|
||||
handler.dispatch_stop(stop);
|
||||
handler.end_scope();
|
||||
}
|
||||
self.current_block = None;
|
||||
}
|
||||
|
||||
fn handle_block_abort(&mut self, abort: &BlockAbort) {
|
||||
let handlers = self.get_block_handlers_mut(abort.block_type);
|
||||
for handler in handlers {
|
||||
handler.dispatch_abort(abort);
|
||||
handler.end_scope();
|
||||
}
|
||||
self.current_block = None;
|
||||
}
|
||||
|
||||
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,
|
||||
BlockType::ToolUse => &mut self.tool_use_block_handlers,
|
||||
BlockType::ToolResult => &mut self.text_block_handlers, // ToolResultはTextとして扱う
|
||||
}
|
||||
}
|
||||
|
||||
/// 現在アクティブなブロックタイプを取得
|
||||
pub fn current_block(&self) -> Option<BlockType> {
|
||||
self.current_block
|
||||
}
|
||||
|
||||
/// 現在アクティブなブロックを中断する
|
||||
///
|
||||
/// キャンセルやエラー時に呼び出し、進行中のブロックに対して
|
||||
/// BlockAbortイベントを発火してスコープをクリーンアップする。
|
||||
pub fn abort_current_block(&mut self) {
|
||||
if let Some(block_type) = self.current_block {
|
||||
let abort = crate::timeline::event::BlockAbort {
|
||||
index: 0, // インデックスは不明なので0
|
||||
block_type,
|
||||
reason: "Cancelled".to_string(),
|
||||
};
|
||||
self.handle_block_abort(&abort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn test_timeline_creation() {
|
||||
let timeline = Timeline::new();
|
||||
assert!(timeline.current_block().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhandled_sse_is_ignored_by_timeline_handlers() {
|
||||
struct TestTextHandler {
|
||||
calls: Arc<Mutex<Vec<TextBlockEvent>>>,
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for TestTextHandler {
|
||||
type Scope = ();
|
||||
fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) {
|
||||
self.calls.lock().unwrap().push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(TestTextHandler {
|
||||
calls: calls.clone(),
|
||||
});
|
||||
|
||||
timeline.dispatch(&Event::UnhandledSse(UnhandledSseEvent {
|
||||
provider: "openai_responses".to_string(),
|
||||
event_type: "response.mystery".to_string(),
|
||||
data_preview: "{}".to_string(),
|
||||
data_len: 2,
|
||||
}));
|
||||
|
||||
assert!(timeline.current_block().is_none());
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_event_dispatch() {
|
||||
// シンプルなテスト用構造体
|
||||
struct TestUsageHandler {
|
||||
calls: Arc<Mutex<Vec<UsageEvent>>>,
|
||||
}
|
||||
|
||||
impl Handler<UsageKind> for TestUsageHandler {
|
||||
type Scope = ();
|
||||
fn on_event(&mut self, _scope: &mut (), event: &UsageEvent) {
|
||||
self.calls.lock().unwrap().push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let handler = TestUsageHandler {
|
||||
calls: calls.clone(),
|
||||
};
|
||||
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_usage(handler);
|
||||
|
||||
timeline.dispatch(&Event::usage(100, 50));
|
||||
// pending_usage に積まれているだけなのでまだ未発火
|
||||
assert_eq!(calls.lock().unwrap().len(), 0);
|
||||
|
||||
// flush で 1 度だけ発火
|
||||
timeline.flush_usage();
|
||||
let recorded = calls.lock().unwrap();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert_eq!(recorded[0].input_tokens, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_aggregation_and_flush() {
|
||||
struct TestUsageHandler {
|
||||
calls: Arc<Mutex<Vec<UsageEvent>>>,
|
||||
}
|
||||
impl Handler<UsageKind> for TestUsageHandler {
|
||||
type Scope = ();
|
||||
fn on_event(&mut self, _scope: &mut (), event: &UsageEvent) {
|
||||
self.calls.lock().unwrap().push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_usage(TestUsageHandler {
|
||||
calls: calls.clone(),
|
||||
});
|
||||
|
||||
// Anthropic 風: message_start で input + 暫定 output
|
||||
timeline.dispatch(&Event::Usage(UsageEvent {
|
||||
input_tokens: Some(409),
|
||||
output_tokens: Some(1),
|
||||
total_tokens: Some(410),
|
||||
cache_read_input_tokens: Some(0),
|
||||
cache_creation_input_tokens: Some(0),
|
||||
}));
|
||||
// message_delta で最終 output
|
||||
timeline.dispatch(&Event::Usage(UsageEvent {
|
||||
input_tokens: Some(409),
|
||||
output_tokens: Some(71),
|
||||
total_tokens: Some(480),
|
||||
cache_read_input_tokens: Some(0),
|
||||
cache_creation_input_tokens: Some(0),
|
||||
}));
|
||||
|
||||
// 未 flush の段階では発火しない
|
||||
assert_eq!(calls.lock().unwrap().len(), 0);
|
||||
|
||||
timeline.flush_usage();
|
||||
let recorded = calls.lock().unwrap();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert_eq!(recorded[0].input_tokens, Some(409));
|
||||
assert_eq!(recorded[0].output_tokens, Some(71));
|
||||
|
||||
// flush 後にもう一度 flush しても何も起きない
|
||||
drop(recorded);
|
||||
timeline.flush_usage();
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! ToolCallCollector - ツール呼び出し収集用ハンドラ
|
||||
//!
|
||||
//! TimelineのToolUseBlockHandler として登録され、
|
||||
//! ストリーム中のToolUseブロックを収集する。
|
||||
|
||||
use crate::{
|
||||
handler::{Handler, ToolUseBlockEvent, ToolUseBlockKind},
|
||||
llm_client::types::parse_tool_arguments,
|
||||
tool::ToolCall,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// ToolUseブロックから収集したツール呼び出し情報を保持
|
||||
///
|
||||
/// ToolCallCollectorのHandler実装で使用するスコープ型
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CollectorState {
|
||||
/// 現在のツール呼び出し情報 (ブロック進行中)
|
||||
current_id: Option<String>,
|
||||
current_name: Option<String>,
|
||||
/// 蓄積中のJSON入力
|
||||
input_json_buffer: String,
|
||||
}
|
||||
|
||||
/// ToolCallCollector - ToolUseブロックハンドラ
|
||||
///
|
||||
/// Timelineに登録してToolUseブロックイベントを受信し、
|
||||
/// 完了したToolCallを収集する。
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCallCollector {
|
||||
/// 収集されたToolCall
|
||||
collected: Arc<Mutex<Vec<ToolCall>>>,
|
||||
}
|
||||
|
||||
impl ToolCallCollector {
|
||||
/// 新しいToolCallCollectorを作成
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
collected: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 収集されたToolCallを取得してクリア
|
||||
pub fn take_collected(&self) -> Vec<ToolCall> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集されたToolCallの参照を取得
|
||||
pub fn collected(&self) -> Vec<ToolCall> {
|
||||
self.collected.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// 収集されたToolCallがあるかどうか
|
||||
pub fn has_pending_calls(&self) -> bool {
|
||||
!self.collected.lock().unwrap().is_empty()
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolCallCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<ToolUseBlockKind> for ToolCallCollector {
|
||||
type Scope = CollectorState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
|
||||
match event {
|
||||
ToolUseBlockEvent::Start(start) => {
|
||||
scope.current_id = Some(start.id.clone());
|
||||
scope.current_name = Some(start.name.clone());
|
||||
scope.input_json_buffer.clear();
|
||||
}
|
||||
ToolUseBlockEvent::InputJsonDelta(delta) => {
|
||||
scope.input_json_buffer.push_str(delta);
|
||||
}
|
||||
ToolUseBlockEvent::Stop(_stop) => {
|
||||
// ブロック完了時にToolCallを確定
|
||||
if let (Some(id), Some(name)) = (scope.current_id.take(), scope.current_name.take())
|
||||
{
|
||||
let input = parse_tool_arguments(&scope.input_json_buffer);
|
||||
|
||||
let tool_call = ToolCall { id, name, input };
|
||||
|
||||
self.collected.lock().unwrap().push(tool_call);
|
||||
}
|
||||
scope.input_json_buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::timeline::Timeline;
|
||||
use crate::timeline::event::Event;
|
||||
|
||||
#[test]
|
||||
fn test_collect_single_tool_call() {
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
// ToolUseブロックのイベントシーケンスをディスパッチ
|
||||
timeline.dispatch(&Event::tool_use_start(0, "tool_123", "get_weather"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"city":"#));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#""Tokyo"}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
// 収集されたToolCallを確認
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].id, "tool_123");
|
||||
assert_eq!(calls[0].name, "get_weather");
|
||||
assert_eq!(calls[0].input["city"], "Tokyo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_empty_buffer_returns_object() {
|
||||
// 引数なしツール呼び出し: input_json_delta が一度も来ないケース
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListItems"));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].id, "tool_empty");
|
||||
assert_eq!(calls[0].name, "ListItems");
|
||||
assert!(calls[0].input.is_object());
|
||||
assert_eq!(
|
||||
calls[0].input,
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_multiple_tool_calls() {
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
// 1つ目のToolCall
|
||||
timeline.dispatch(&Event::tool_use_start(0, "call_1", "tool_a"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"a":1}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
// 2つ目のToolCall
|
||||
timeline.dispatch(&Event::tool_use_start(1, "call_2", "tool_b"));
|
||||
timeline.dispatch(&Event::tool_input_delta(1, r#"{"b":2}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 2);
|
||||
assert_eq!(calls[0].name, "tool_a");
|
||||
assert_eq!(calls[1].name, "tool_b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Usage 履歴ベースのトークン会計(汎用部分)。
|
||||
//!
|
||||
//! `UsageRecord` の列(プロバイダ実測値)と現在の history から、
|
||||
//! 任意の history index 時点のプロンプト全長トークン数を pure に計算する。
|
||||
//!
|
||||
//! # 方針
|
||||
//!
|
||||
//! - ローカルトークナイザは持たない。実測値があればそれを採用し、
|
||||
//! measurement 間はバイト数で按分、最新 measurement より先は byte/4
|
||||
//! fallback で外挿する
|
||||
//! - 推定の出どころは [`EstimateSource`] で呼び出し側に明示する。
|
||||
//! 課金判断には使えないが、compact / prune / memory extract trigger 等の
|
||||
//! 閾値判定には十分な精度
|
||||
//! - `records` は `history_len` 昇順を仮定する(呼び出し側がそのように積む)
|
||||
|
||||
use crate::{Item, UsageRecord};
|
||||
|
||||
/// 推定の出どころ。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EstimateSource {
|
||||
/// measurement の境界にちょうど一致(実測値そのもの)
|
||||
Measured,
|
||||
/// 連続する 2 つの measurement の間をバイト按分で計算
|
||||
Interpolated,
|
||||
/// 最後の measurement より新しい区間を byte/4 fallback で外挿
|
||||
Extrapolated,
|
||||
/// measurement が 1 件も無く、バイト数のみのフォールバック
|
||||
NoData,
|
||||
}
|
||||
|
||||
/// トークン数の推定値。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TokenEstimate {
|
||||
pub tokens: u64,
|
||||
pub source: EstimateSource,
|
||||
}
|
||||
|
||||
/// `items[..i]` までの累積バイト数(`prefix[i]`)を返す。長さは `items.len()+1`。
|
||||
pub fn prefix_bytes(items: &[Item]) -> Vec<u64> {
|
||||
let mut prefix = Vec::with_capacity(items.len() + 1);
|
||||
let mut acc: u64 = 0;
|
||||
prefix.push(0);
|
||||
for item in items {
|
||||
acc = acc.saturating_add(item_bytes(item));
|
||||
prefix.push(acc);
|
||||
}
|
||||
prefix
|
||||
}
|
||||
|
||||
/// 1 Item の大きさ。JSON シリアライズ長を使う粗い近似。
|
||||
/// トークン数との絶対変換ではなく区間の按分にしか使わないので、
|
||||
/// プロバイダごとの overhead は比率でキャンセルされる。
|
||||
pub fn item_bytes(item: &Item) -> u64 {
|
||||
serde_json::to_string(item)
|
||||
.map(|s| s.len() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `history[..index]` までのトークン数を推定する。
|
||||
///
|
||||
/// `prefix` は [`prefix_bytes`] で得た `history.len() + 1` 長の累積バイト列。
|
||||
/// 呼び出し側が 1 度だけ計算して使い回すことで、線形探索や複数回の推定が
|
||||
/// O(n) シリアライズで済む(内部で毎回再計算すると O(n²) になる)。
|
||||
pub fn tokens_at(
|
||||
history: &[Item],
|
||||
records: &[UsageRecord],
|
||||
index: usize,
|
||||
prefix: &[u64],
|
||||
) -> TokenEstimate {
|
||||
debug_assert!(index <= history.len());
|
||||
debug_assert_eq!(prefix.len(), history.len() + 1);
|
||||
|
||||
if index == 0 {
|
||||
return TokenEstimate {
|
||||
tokens: 0,
|
||||
source: EstimateSource::Measured,
|
||||
};
|
||||
}
|
||||
|
||||
if records.is_empty() {
|
||||
return TokenEstimate {
|
||||
tokens: prefix[index] / 4,
|
||||
source: EstimateSource::NoData,
|
||||
};
|
||||
}
|
||||
|
||||
// exact match(rev 走査で一番新しい record を採用)
|
||||
if let Some(r) = records.iter().rev().find(|r| r.history_len == index) {
|
||||
return TokenEstimate {
|
||||
tokens: r.input_total_tokens,
|
||||
source: EstimateSource::Measured,
|
||||
};
|
||||
}
|
||||
|
||||
let lower = records.iter().rev().find(|r| r.history_len < index);
|
||||
let upper = records.iter().find(|r| r.history_len > index);
|
||||
let cap = history.len();
|
||||
|
||||
match (lower, upper) {
|
||||
(Some(lo), Some(up)) => {
|
||||
let lo_bytes = prefix[lo.history_len.min(cap)];
|
||||
let up_bytes = prefix[up.history_len.min(cap)];
|
||||
let at_bytes = prefix[index];
|
||||
let span_bytes = up_bytes.saturating_sub(lo_bytes);
|
||||
let span_tokens = up.input_total_tokens.saturating_sub(lo.input_total_tokens);
|
||||
if span_bytes == 0 || span_tokens == 0 {
|
||||
return TokenEstimate {
|
||||
tokens: lo.input_total_tokens,
|
||||
source: EstimateSource::Interpolated,
|
||||
};
|
||||
}
|
||||
let delta_bytes = at_bytes.saturating_sub(lo_bytes);
|
||||
let delta_tokens =
|
||||
(delta_bytes as u128 * span_tokens as u128 / span_bytes as u128) as u64;
|
||||
TokenEstimate {
|
||||
tokens: lo.input_total_tokens + delta_tokens,
|
||||
source: EstimateSource::Interpolated,
|
||||
}
|
||||
}
|
||||
(Some(lo), None) => {
|
||||
let lo_bytes = prefix[lo.history_len.min(cap)];
|
||||
let at_bytes = prefix[index];
|
||||
let delta_bytes = at_bytes.saturating_sub(lo_bytes);
|
||||
|
||||
TokenEstimate {
|
||||
tokens: lo.input_total_tokens.saturating_add(delta_bytes / 4),
|
||||
source: EstimateSource::Extrapolated,
|
||||
}
|
||||
}
|
||||
(None, Some(up)) => {
|
||||
let up_bytes = prefix[up.history_len.min(cap)];
|
||||
let at_bytes = prefix[index];
|
||||
if up_bytes == 0 {
|
||||
return TokenEstimate {
|
||||
tokens: 0,
|
||||
source: EstimateSource::Interpolated,
|
||||
};
|
||||
}
|
||||
let t = (at_bytes as u128 * up.input_total_tokens as u128 / up_bytes as u128) as u64;
|
||||
TokenEstimate {
|
||||
tokens: t,
|
||||
source: EstimateSource::Interpolated,
|
||||
}
|
||||
}
|
||||
(None, None) => unreachable!("records non-empty but neither lower nor upper matched"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 現在の history 全体の推定トークン数。
|
||||
pub fn total_tokens(history: &[Item], records: &[UsageRecord]) -> TokenEstimate {
|
||||
let prefix = prefix_bytes(history);
|
||||
tokens_at(history, records, history.len(), &prefix)
|
||||
}
|
||||
|
||||
/// 任意の history index 時点でのプロンプト全長推定。
|
||||
/// `history_len == 0` で 0 を返す。delta 計算 (extract trigger 等) で
|
||||
/// `total_tokens_at(now) - total_tokens_at(pointer)` の形で使う。
|
||||
pub fn total_tokens_at(
|
||||
history: &[Item],
|
||||
records: &[UsageRecord],
|
||||
history_len: usize,
|
||||
) -> TokenEstimate {
|
||||
let prefix = prefix_bytes(history);
|
||||
tokens_at(history, records, history_len.min(history.len()), &prefix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn msg(text: &str) -> Item {
|
||||
Item::user_message(text)
|
||||
}
|
||||
|
||||
fn record(history_len: usize, tokens: u64) -> UsageRecord {
|
||||
UsageRecord {
|
||||
history_len,
|
||||
input_total_tokens: tokens,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_no_data_falls_back_to_byte_estimate() {
|
||||
let history = vec![msg("hello world")];
|
||||
let est = total_tokens(&history, &[]);
|
||||
assert_eq!(est.source, EstimateSource::NoData);
|
||||
assert!(est.tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_measured_when_last_record_matches_history_len() {
|
||||
let history = vec![msg("a"), msg("b"), msg("c")];
|
||||
let records = vec![record(3, 120)];
|
||||
let est = total_tokens(&history, &records);
|
||||
assert_eq!(est.source, EstimateSource::Measured);
|
||||
assert_eq!(est.tokens, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_extrapolated_when_history_grew_past_last_measurement() {
|
||||
let history = vec![msg("a"), msg("b"), msg("c"), msg("d")];
|
||||
let records = vec![record(3, 100)];
|
||||
let est = total_tokens(&history, &records);
|
||||
assert_eq!(est.source, EstimateSource::Extrapolated);
|
||||
assert!(est.tokens > 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrapolation_after_single_measurement_uses_byte_fallback_not_total_prompt_rate() {
|
||||
let history = vec![msg("first"), msg(&"tool output ".repeat(400))];
|
||||
let records = vec![record(1, 11_124)];
|
||||
let prefix = prefix_bytes(&history);
|
||||
let delta_bytes = prefix[2].saturating_sub(prefix[1]);
|
||||
|
||||
let est = total_tokens(&history, &records);
|
||||
|
||||
assert_eq!(est.source, EstimateSource::Extrapolated);
|
||||
assert_eq!(est.tokens, 11_124 + delta_bytes / 4);
|
||||
|
||||
let old_projection =
|
||||
11_124 + (delta_bytes as u128 * 11_124_u128 / prefix[1] as u128) as u64;
|
||||
assert!(
|
||||
old_projection > est.tokens.saturating_mul(10),
|
||||
"old_projection={old_projection}, corrected={}",
|
||||
est.tokens
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrapolation_after_multiple_measurements_uses_byte_fallback_for_unmeasured_delta() {
|
||||
let history = vec![
|
||||
msg("first"),
|
||||
msg(&"measured increment ".repeat(20)),
|
||||
msg(&"unmeasured increment ".repeat(30)),
|
||||
];
|
||||
let records = vec![record(1, 10_000), record(2, 10_200)];
|
||||
let prefix = prefix_bytes(&history);
|
||||
let delta_bytes = prefix[3].saturating_sub(prefix[2]);
|
||||
|
||||
let est = total_tokens(&history, &records);
|
||||
|
||||
assert_eq!(est.source, EstimateSource::Extrapolated);
|
||||
assert_eq!(est.tokens, 10_200 + delta_bytes / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrapolation_does_not_reuse_measured_rate_after_context_projection() {
|
||||
let compacted_span = msg("x");
|
||||
let projected = vec![
|
||||
msg("first"),
|
||||
msg("summary only"),
|
||||
compacted_span,
|
||||
msg("new user input"),
|
||||
];
|
||||
let records = vec![record(1, 10_000), record(3, 30_000)];
|
||||
let prefix = prefix_bytes(&projected);
|
||||
let delta_bytes = prefix[4].saturating_sub(prefix[3]);
|
||||
|
||||
let est = total_tokens(&projected, &records);
|
||||
|
||||
assert_eq!(est.source, EstimateSource::Extrapolated);
|
||||
assert_eq!(est.tokens, 30_000 + delta_bytes / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_zero_history_is_zero() {
|
||||
let est = total_tokens(&[], &[]);
|
||||
assert_eq!(est.tokens, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Tool Definition
|
||||
//!
|
||||
//! Traits for defining tools callable by LLM.
|
||||
//! Usually auto-implemented using the `#[tool]` macro.
|
||||
|
||||
use std::{collections::HashMap, fmt, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error during tool execution
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ToolError {
|
||||
/// Invalid argument
|
||||
#[error("Invalid argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
/// Execution failed
|
||||
#[error("Execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolOutput - Tool execution result with summary + content
|
||||
// =============================================================================
|
||||
|
||||
/// Threshold below which tool output is treated as summary-only (no content).
|
||||
/// Outputs this small don't benefit from pruning.
|
||||
pub const SUMMARY_THRESHOLD: usize = 200;
|
||||
|
||||
/// Byte-size caps applied to tool execution `content` at the Engine's
|
||||
/// tool-execution boundary, before results enter conversation history.
|
||||
///
|
||||
/// Exists so a single oversized tool result (e.g. a wide `Glob` scan)
|
||||
/// cannot blow past the provider's per-minute input-token rate limit.
|
||||
/// Individual tools are not trusted to self-limit — this is the single
|
||||
/// chokepoint.
|
||||
///
|
||||
/// The unit is bytes rather than tokens because accurate pre-send token
|
||||
/// estimation is not available. The limits can be migrated to token
|
||||
/// units later without changing callers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolOutputLimits {
|
||||
/// Cap applied to any tool not listed in `per_tool`.
|
||||
pub default_max_bytes: usize,
|
||||
/// Per-tool overrides, keyed by tool registration name.
|
||||
pub per_tool: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl ToolOutputLimits {
|
||||
/// Resolve the cap for a given tool name.
|
||||
pub fn limit_for(&self, tool_name: &str) -> usize {
|
||||
self.per_tool
|
||||
.get(tool_name)
|
||||
.copied()
|
||||
.unwrap_or(self.default_max_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate `content` in-place if it exceeds `limit` bytes, replacing
|
||||
/// the dropped tail with a short human- and LLM-readable marker so the
|
||||
/// model can self-correct by narrowing its query.
|
||||
///
|
||||
/// The cut point is walked back to the nearest UTF-8 char boundary so
|
||||
/// multibyte characters are never split.
|
||||
pub(crate) fn truncate_content(content: &mut String, limit: usize) {
|
||||
let original_len = content.len();
|
||||
if original_len <= limit {
|
||||
return;
|
||||
}
|
||||
|
||||
let suffix_template = "\n\n[truncated: %BYTES% bytes dropped, refine your query]";
|
||||
// Reserve enough headroom for the suffix (upper bound on the byte length
|
||||
// of the number substitution). usize::MAX fits in 20 digits.
|
||||
let reserved = suffix_template.len() + 20 - "%BYTES%".len();
|
||||
let body_budget = limit.saturating_sub(reserved);
|
||||
|
||||
let mut cut = body_budget.min(original_len);
|
||||
while cut > 0 && !content.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
content.truncate(cut);
|
||||
let dropped = original_len - cut;
|
||||
content.push_str(&suffix_template.replace("%BYTES%", &dropped.to_string()));
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ImageAttachment {
|
||||
mime_type: String,
|
||||
data: Arc<[u8]>,
|
||||
}
|
||||
|
||||
impl ImageAttachment {
|
||||
pub fn new(mime_type: impl Into<String>, data: impl Into<Arc<[u8]>>) -> Self {
|
||||
Self {
|
||||
mime_type: mime_type.into(),
|
||||
data: data.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mime_type(&self) -> &str {
|
||||
&self.mime_type
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ImageAttachment {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ImageAttachment")
|
||||
.field("mime_type", &self.mime_type)
|
||||
.field("bytes", &self.data.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ImageAttachmentWire {
|
||||
mime_type: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
impl Serialize for ImageAttachment {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
ImageAttachmentWire {
|
||||
mime_type: self.mime_type.clone(),
|
||||
data: STANDARD.encode(self.data.as_ref()),
|
||||
}
|
||||
.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ImageAttachment {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = ImageAttachmentWire::deserialize(deserializer)?;
|
||||
let data = STANDARD.decode(wire.data).map_err(D::Error::custom)?;
|
||||
Ok(Self::new(wire.mime_type, data))
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable binary detail emitted by a tool and handled by normal ToolResult pruning.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
|
||||
pub enum Attachment {
|
||||
Image(ImageAttachment),
|
||||
}
|
||||
|
||||
/// Tool execution result.
|
||||
///
|
||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||
/// conversation history even after pruning. Optional text and binary details are
|
||||
/// committed to history and may later be omitted only by normal ToolResult pruning.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolOutput {
|
||||
/// Short summary (1-2 lines). Always remains in history.
|
||||
pub summary: String,
|
||||
/// Detailed text output. Removed by Prune when old enough.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Durable binary details handled by the same pruning lifecycle as `content`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
impl From<String> for ToolOutput {
|
||||
fn from(s: String) -> Self {
|
||||
if s.len() <= SUMMARY_THRESHOLD {
|
||||
ToolOutput {
|
||||
summary: s,
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
let lines = s.lines().count();
|
||||
let first_line: String = s.lines().next().unwrap_or("").chars().take(80).collect();
|
||||
let summary = format!("{lines} lines | {first_line}…");
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(s),
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolMeta - Immutable Meta Information
|
||||
// =============================================================================
|
||||
|
||||
/// Origin metadata for a registered tool.
|
||||
///
|
||||
/// This metadata is intentionally not part of the provider-facing tool schema.
|
||||
/// It lets host layers audit where a model-visible tool definition came from
|
||||
/// while keeping execution and permission semantics in the normal Engine path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolOrigin {
|
||||
/// Origin kind, for example `plugin` or `builtin`.
|
||||
pub kind: String,
|
||||
/// Package-local plugin id.
|
||||
pub plugin_id: String,
|
||||
/// Source-qualified plugin/package reference when `kind == "plugin"`.
|
||||
pub plugin_ref: String,
|
||||
/// Plugin source such as `user`, `project`, or `builtin`.
|
||||
pub source: String,
|
||||
/// Resolved package digest.
|
||||
pub digest: String,
|
||||
/// Resolved package version.
|
||||
pub package_version: String,
|
||||
/// Plugin API/schema version declared by the package.
|
||||
pub package_api_version: u32,
|
||||
/// Surface that contributed this tool. Plugin tools use `tool`.
|
||||
pub surface: String,
|
||||
}
|
||||
|
||||
/// Tool meta information (fixed at registration, immutable)
|
||||
///
|
||||
/// Generated from `ToolDefinition` factory and does not change after registration with Engine.
|
||||
/// Used for sending tool definitions to LLM.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolMeta {
|
||||
/// Tool name (used by LLM for identification)
|
||||
pub name: String,
|
||||
/// Tool description (included in prompt to LLM)
|
||||
pub description: String,
|
||||
/// JSON Schema for arguments
|
||||
pub input_schema: Value,
|
||||
/// Optional host-side origin metadata. This is not exposed to the LLM.
|
||||
pub origin: Option<ToolOrigin>,
|
||||
}
|
||||
|
||||
impl ToolMeta {
|
||||
/// Create a new ToolMeta
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
input_schema: Value::Object(Default::default()),
|
||||
origin: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the description
|
||||
pub fn description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = desc.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the argument schema
|
||||
pub fn input_schema(mut self, schema: Value) -> Self {
|
||||
self.input_schema = schema;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set host-side origin metadata.
|
||||
pub fn origin(mut self, origin: ToolOrigin) -> Self {
|
||||
self.origin = Some(origin);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolDefinition - Factory Type
|
||||
// =============================================================================
|
||||
|
||||
/// Tool definition factory
|
||||
///
|
||||
/// When called, returns `(ToolMeta, Arc<dyn Tool>)`.
|
||||
/// Called once during Engine registration, and the meta information and instance
|
||||
/// are cached at session scope.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let def: ToolDefinition = Arc::new(|| {
|
||||
/// (
|
||||
/// ToolMeta::new("my_tool")
|
||||
/// .description("My tool description")
|
||||
/// .input_schema(json!({"type": "object"})),
|
||||
/// Arc::new(MyToolImpl { state: 0 }) as Arc<dyn Tool>,
|
||||
/// )
|
||||
/// });
|
||||
/// engine.register_tool(def)?;
|
||||
/// ```
|
||||
pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Sync>;
|
||||
|
||||
/// Per-call context supplied by the engine when executing a tool call.
|
||||
///
|
||||
/// The context identifies a tool call within one assistant response's tool-call
|
||||
/// batch without imposing any scheduling policy on the engine. Tool
|
||||
/// implementations may use it for response-local ordering, diagnostics, or
|
||||
/// correlation, but it is intentionally not a handle to engine state, history,
|
||||
/// or session mutation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolExecutionContext {
|
||||
/// Provider/tool-call id for the call being executed.
|
||||
pub call_id: String,
|
||||
/// Engine-local identity shared by all tool calls from one execution batch.
|
||||
pub batch_id: String,
|
||||
/// Zero-based order of this call in the model-returned tool-call list.
|
||||
pub call_index: usize,
|
||||
}
|
||||
|
||||
impl ToolExecutionContext {
|
||||
pub fn new(call_id: impl Into<String>, batch_id: impl Into<String>, call_index: usize) -> Self {
|
||||
Self {
|
||||
call_id: call_id.into(),
|
||||
batch_id: batch_id.into(),
|
||||
call_index,
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
||||
pub fn direct() -> Self {
|
||||
Self::new("direct", "direct", 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolExecutionContext {
|
||||
fn default() -> Self {
|
||||
Self::direct()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool trait
|
||||
// =============================================================================
|
||||
|
||||
/// Trait for defining tools callable by LLM
|
||||
///
|
||||
/// Tools are used by LLM to access external resources
|
||||
/// or execute computations.
|
||||
/// Can maintain state during the session.
|
||||
///
|
||||
/// # How to Implement
|
||||
///
|
||||
/// Usually auto-implemented using the `#[tool_registry]` macro:
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[tool_registry]
|
||||
/// impl MyApp {
|
||||
/// #[tool]
|
||||
/// async fn search(&self, query: String) -> String {
|
||||
/// format!("Results for: {}", query)
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Register
|
||||
/// engine.register_tool(app.search_definition())?;
|
||||
/// ```
|
||||
///
|
||||
/// # Manual Implementation
|
||||
///
|
||||
/// ```ignore
|
||||
/// use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolDefinition, ToolOutput};
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// struct MyTool { counter: std::sync::atomic::AtomicUsize }
|
||||
///
|
||||
/// #[async_trait::async_trait]
|
||||
/// impl Tool for MyTool {
|
||||
/// async fn execute(&self, input: &str, ctx: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
/// self.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
/// Ok(format!("call {}: {}", ctx.call_index, input).into())
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// let def: ToolDefinition = Arc::new(|| {
|
||||
/// (
|
||||
/// ToolMeta::new("my_tool")
|
||||
/// .description("My custom tool")
|
||||
/// .input_schema(serde_json::json!({"type": "object"})),
|
||||
/// Arc::new(MyTool { counter: Default::default() }) as Arc<dyn Tool>,
|
||||
/// )
|
||||
/// });
|
||||
/// ```
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
/// Execute the tool.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `input_json` - JSON-formatted arguments generated by LLM
|
||||
/// * `ctx` - response-local call identity and ordering context
|
||||
///
|
||||
/// # Returns
|
||||
/// A [`ToolOutput`] with summary and optional detailed content.
|
||||
/// For simple cases, use `From<String>`: `Ok("done".to_string().into())`
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Call / Result Types
|
||||
// =============================================================================
|
||||
|
||||
/// Tool call information
|
||||
///
|
||||
/// Represents a ToolUse block from LLM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
/// Tool call ID (used for linking with response)
|
||||
pub id: String,
|
||||
/// Tool name
|
||||
pub name: String,
|
||||
/// Input arguments (JSON)
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
/// Tool execution result
|
||||
///
|
||||
/// Intermediate representation between tool execution and history.
|
||||
/// Carries `summary` + optional `content` from [`ToolOutput`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ToolResult {
|
||||
/// Corresponding tool call ID
|
||||
pub tool_use_id: String,
|
||||
/// Short summary (always kept in history)
|
||||
pub summary: String,
|
||||
/// Detailed output (prunable)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Whether this is an error
|
||||
#[serde(default)]
|
||||
pub is_error: bool,
|
||||
/// Durable binary details (prunable with `content`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Create a success result from a [`ToolOutput`].
|
||||
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
summary: output.summary,
|
||||
content: output.content,
|
||||
is_error: false,
|
||||
attachments: output.attachments,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error result.
|
||||
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
summary: message.into(),
|
||||
content: None,
|
||||
is_error: true,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod truncate_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn noop_when_within_limit() {
|
||||
let mut s = "hello world".to_string();
|
||||
truncate_content(&mut s, 1024);
|
||||
assert_eq!(s, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_at_exact_limit() {
|
||||
let mut s = "a".repeat(100);
|
||||
truncate_content(&mut s, 100);
|
||||
assert_eq!(s.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_oversized_ascii_with_marker() {
|
||||
let mut s = "a".repeat(1000);
|
||||
truncate_content(&mut s, 200);
|
||||
assert!(s.contains("[truncated:"));
|
||||
assert!(s.contains("refine your query"));
|
||||
assert!(s.len() <= 200, "result was {} bytes", s.len());
|
||||
let dropped: usize = s
|
||||
.split("[truncated: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let body_len = s.find("\n\n[truncated:").unwrap();
|
||||
assert_eq!(body_len + dropped, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_utf8_char_boundaries() {
|
||||
// 100 copies of "あ" (3 bytes each) = 300 bytes.
|
||||
let mut s = "あ".repeat(100);
|
||||
truncate_content(&mut s, 120);
|
||||
// Truncation must not split a multibyte character.
|
||||
assert!(s.is_char_boundary(s.find("\n\n[truncated:").unwrap_or(s.len())));
|
||||
// And the result must still be valid UTF-8 (implicitly true for String).
|
||||
assert!(s.contains("[truncated:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_per_tool_override() {
|
||||
let mut limits = ToolOutputLimits {
|
||||
default_max_bytes: 1024,
|
||||
per_tool: HashMap::new(),
|
||||
};
|
||||
limits.per_tool.insert("Read".to_string(), 4096);
|
||||
assert_eq!(limits.limit_for("Read"), 4096);
|
||||
assert_eq!(limits.limit_for("Grep"), 1024);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::llm_client::ToolDefinition as LlmToolDefinition;
|
||||
use crate::tool::{
|
||||
Tool, ToolDefinition as EngineToolDefinition, ToolExecutionContext, ToolMeta, ToolOutput,
|
||||
};
|
||||
|
||||
type ToolMap = HashMap<String, (ToolMeta, Arc<dyn Tool>)>;
|
||||
|
||||
/// Errors produced by ToolServer operations.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ToolServerError {
|
||||
/// A tool with the same name already exists.
|
||||
#[error("Tool with name '{0}' already registered")]
|
||||
DuplicateName(String),
|
||||
/// Requested tool was not found.
|
||||
#[error("Tool '{0}' not found")]
|
||||
ToolNotFound(String),
|
||||
/// Tool execution failed.
|
||||
#[error("Tool execution failed: {0}")]
|
||||
ToolExecution(String),
|
||||
}
|
||||
|
||||
/// In-memory tool server.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ToolServer {
|
||||
tools: Arc<Mutex<ToolMap>>,
|
||||
pending: Arc<Mutex<Vec<EngineToolDefinition>>>,
|
||||
}
|
||||
|
||||
impl ToolServer {
|
||||
/// Create a new empty tool server.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a handle for shared access.
|
||||
pub fn handle(&self) -> ToolServerHandle {
|
||||
ToolServerHandle {
|
||||
tools: Arc::clone(&self.tools),
|
||||
pending: Arc::clone(&self.pending),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shareable handle to a tool server.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ToolServerHandle {
|
||||
tools: Arc<Mutex<ToolMap>>,
|
||||
pending: Arc<Mutex<Vec<EngineToolDefinition>>>,
|
||||
}
|
||||
|
||||
impl ToolServerHandle {
|
||||
/// Queue a tool factory for deferred initialization.
|
||||
///
|
||||
/// The factory is **not** called here; it is stored and executed
|
||||
/// when [`flush_pending`](Self::flush_pending) is called (typically
|
||||
/// at the start of `Engine::run()`).
|
||||
pub(crate) fn register_tool(&self, factory: EngineToolDefinition) {
|
||||
self.pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(factory);
|
||||
}
|
||||
|
||||
/// Queue many tool factories for deferred initialization.
|
||||
pub(crate) fn register_tools(&self, factories: impl IntoIterator<Item = EngineToolDefinition>) {
|
||||
let mut guard = self.pending.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.extend(factories);
|
||||
}
|
||||
|
||||
/// Execute all pending factories and register the resulting tools.
|
||||
///
|
||||
/// Called implicitly by `Engine::lock()` before the first turn.
|
||||
/// Exposed as `pub` so callers can force-materialise tools earlier —
|
||||
/// for example when building a system-prompt template context that needs
|
||||
/// the list of registered tool names. Redundant calls are no-ops.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if any factory produces a tool whose name collides with
|
||||
/// an already-registered tool. Duplicate names are a programming
|
||||
/// error and should be caught during development.
|
||||
pub fn flush_pending(&self) {
|
||||
let pending: Vec<_> = {
|
||||
let mut guard = self.pending.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::mem::take(&mut *guard)
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Execute all factories first, then validate and insert atomically.
|
||||
let materialized: Vec<_> = pending.into_iter().map(|f| f()).collect();
|
||||
let mut tools = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (meta, instance) in materialized {
|
||||
assert!(
|
||||
!tools.contains_key(&meta.name),
|
||||
"duplicate tool name: '{}'",
|
||||
meta.name,
|
||||
);
|
||||
tools.insert(meta.name.clone(), (meta, instance));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a tool by name for hook contexts.
|
||||
pub fn get_tool(&self, name: &str) -> Option<(ToolMeta, Arc<dyn Tool>)> {
|
||||
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard
|
||||
.get(name)
|
||||
.map(|(meta, tool)| (meta.clone(), Arc::clone(tool)))
|
||||
}
|
||||
|
||||
/// Execute a tool by name.
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
input_json: &str,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolServerError> {
|
||||
let tool = {
|
||||
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (_, tool) = guard
|
||||
.get(name)
|
||||
.ok_or_else(|| ToolServerError::ToolNotFound(name.to_string()))?;
|
||||
Arc::clone(tool)
|
||||
};
|
||||
tool.execute(input_json, ctx)
|
||||
.await
|
||||
.map_err(|e| ToolServerError::ToolExecution(e.to_string()))
|
||||
}
|
||||
|
||||
/// Remove a registered tool by name.
|
||||
///
|
||||
/// In-flight calls that already obtained an `Arc<dyn Tool>` clone are
|
||||
/// unaffected and will run to completion.
|
||||
pub fn unregister(&self, name: &str) -> Result<(), ToolServerError> {
|
||||
let mut guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard
|
||||
.remove(name)
|
||||
.map(|_| ())
|
||||
.ok_or_else(|| ToolServerError::ToolNotFound(name.to_string()))
|
||||
}
|
||||
|
||||
/// Replace an existing tool with a new implementation.
|
||||
///
|
||||
/// The factory is called immediately and the resulting tool overwrites
|
||||
/// the entry with the same name. Returns `ToolNotFound` if the name
|
||||
/// produced by the factory does not match any registered tool.
|
||||
pub fn replace(&self, factory: EngineToolDefinition) -> Result<(), ToolServerError> {
|
||||
let (meta, instance) = factory();
|
||||
let mut guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !guard.contains_key(&meta.name) {
|
||||
return Err(ToolServerError::ToolNotFound(meta.name));
|
||||
}
|
||||
guard.insert(meta.name.clone(), (meta, instance));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build deterministic tool definitions sorted by tool name.
|
||||
pub fn tool_definitions_sorted(&self) -> Vec<LlmToolDefinition> {
|
||||
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut defs: Vec<_> = guard
|
||||
.values()
|
||||
.map(|(meta, _)| {
|
||||
LlmToolDefinition::new(&meta.name)
|
||||
.description(&meta.description)
|
||||
.input_schema(meta.input_schema.clone())
|
||||
})
|
||||
.collect();
|
||||
defs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
defs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
|
||||
|
||||
struct EchoTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EchoTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: crate::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(input_json.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
fn def(name: &'static str) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(name)
|
||||
.description(format!("desc-{name}"))
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(EchoTool) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_pending_registers_tools() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.register_tool(def("beta"));
|
||||
|
||||
// Before flush, no tools are available
|
||||
assert!(handle.get_tool("alpha").is_none());
|
||||
|
||||
handle.flush_pending();
|
||||
|
||||
// After flush, tools are available
|
||||
assert!(handle.get_tool("alpha").is_some());
|
||||
assert!(handle.get_tool("beta").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "duplicate tool name: 'alpha'")]
|
||||
fn flush_pending_duplicate_name_panics() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.flush_pending();
|
||||
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.flush_pending(); // panics
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_tool_success_and_not_found() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("echo"));
|
||||
handle.flush_pending();
|
||||
|
||||
let out = handle
|
||||
.call_tool("echo", r#"{"x":1}"#, Default::default())
|
||||
.await
|
||||
.expect("call");
|
||||
assert_eq!(out.summary, r#"{"x":1}"#);
|
||||
assert!(out.content.is_none());
|
||||
|
||||
let err = handle
|
||||
.call_tool("missing", "{}", Default::default())
|
||||
.await
|
||||
.expect_err("missing tool");
|
||||
assert_eq!(err, ToolServerError::ToolNotFound("missing".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_definitions_are_sorted() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("zeta"));
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.register_tool(def("beta"));
|
||||
handle.flush_pending();
|
||||
|
||||
let names: Vec<_> = handle
|
||||
.tool_definitions_sorted()
|
||||
.into_iter()
|
||||
.map(|d| d.name)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["alpha", "beta", "zeta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_pending_is_noop_when_empty() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.flush_pending();
|
||||
handle.flush_pending();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_removes_tool() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.flush_pending();
|
||||
|
||||
handle.unregister("alpha").expect("unregister");
|
||||
assert!(handle.get_tool("alpha").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_not_found() {
|
||||
let handle = ToolServer::new().handle();
|
||||
let err = handle.unregister("ghost").expect_err("should fail");
|
||||
assert_eq!(err, ToolServerError::ToolNotFound("ghost".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_swaps_implementation() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.flush_pending();
|
||||
|
||||
// Replace with a tool that returns a fixed string.
|
||||
struct FixedTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FixedTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: crate::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok("replaced".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
let replacement: ToolDefinition = Arc::new(|| {
|
||||
(
|
||||
ToolMeta::new("alpha")
|
||||
.description("replaced-desc")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(FixedTool) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
handle.replace(replacement).expect("replace");
|
||||
|
||||
let (meta, _) = handle.get_tool("alpha").expect("exists");
|
||||
assert_eq!(meta.description, "replaced-desc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_updates_call_result() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("echo"));
|
||||
handle.flush_pending();
|
||||
|
||||
struct ConstTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ConstTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: crate::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok("const".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
let replacement: ToolDefinition = Arc::new(|| {
|
||||
(
|
||||
ToolMeta::new("echo")
|
||||
.description("const")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(ConstTool) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
handle.replace(replacement).expect("replace");
|
||||
|
||||
let out = handle
|
||||
.call_tool("echo", "{}", Default::default())
|
||||
.await
|
||||
.expect("call");
|
||||
assert_eq!(out.summary, "const");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregister_during_execution_does_not_affect_inflight() {
|
||||
use tokio::sync::Notify;
|
||||
|
||||
let started = Arc::new(Notify::new());
|
||||
let finish = Arc::new(Notify::new());
|
||||
|
||||
struct GatedTool {
|
||||
started: Arc<Notify>,
|
||||
finish: Arc<Notify>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GatedTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: crate::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.started.notify_one();
|
||||
self.finish.notified().await;
|
||||
Ok("done".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
let handle = ToolServer::new().handle();
|
||||
let s = Arc::clone(&started);
|
||||
let f = Arc::clone(&finish);
|
||||
handle.register_tool(Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("slow")
|
||||
.description("slow")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(GatedTool {
|
||||
started: Arc::clone(&s),
|
||||
finish: Arc::clone(&f),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
}));
|
||||
handle.flush_pending();
|
||||
|
||||
let h = handle.clone();
|
||||
let call = tokio::spawn(async move { h.call_tool("slow", "{}", Default::default()).await });
|
||||
|
||||
// Wait until the tool is actually executing.
|
||||
started.notified().await;
|
||||
|
||||
// Unregister while the tool is mid-execution.
|
||||
handle.unregister("slow").expect("unregister");
|
||||
assert!(handle.get_tool("slow").is_none());
|
||||
|
||||
// Let the in-flight call finish.
|
||||
finish.notify_one();
|
||||
let result = call.await.expect("join");
|
||||
assert_eq!(result.expect("call").summary, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_during_execution_inflight_uses_old_impl() {
|
||||
use tokio::sync::Notify;
|
||||
|
||||
let started = Arc::new(Notify::new());
|
||||
let finish = Arc::new(Notify::new());
|
||||
|
||||
struct OldTool {
|
||||
started: Arc<Notify>,
|
||||
finish: Arc<Notify>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for OldTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: crate::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.started.notify_one();
|
||||
self.finish.notified().await;
|
||||
Ok("old".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
let handle = ToolServer::new().handle();
|
||||
let s = Arc::clone(&started);
|
||||
let f = Arc::clone(&finish);
|
||||
handle.register_tool(Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("t")
|
||||
.description("d")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(OldTool {
|
||||
started: Arc::clone(&s),
|
||||
finish: Arc::clone(&f),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
}));
|
||||
handle.flush_pending();
|
||||
|
||||
let h = handle.clone();
|
||||
let call = tokio::spawn(async move { h.call_tool("t", "{}", Default::default()).await });
|
||||
|
||||
// Wait until the old tool is mid-execution.
|
||||
started.notified().await;
|
||||
|
||||
// Replace while the old tool is executing.
|
||||
struct NewTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for NewTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: crate::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok("new".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
handle
|
||||
.replace(Arc::new(|| {
|
||||
(
|
||||
ToolMeta::new("t")
|
||||
.description("d")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(NewTool) as Arc<dyn Tool>,
|
||||
)
|
||||
}))
|
||||
.expect("replace");
|
||||
|
||||
// Let the old in-flight call finish — it should return "old".
|
||||
finish.notify_one();
|
||||
let result = call.await.expect("join");
|
||||
assert_eq!(result.expect("call").summary, "old");
|
||||
|
||||
// New calls use the replacement.
|
||||
let out = handle
|
||||
.call_tool("t", "{}", Default::default())
|
||||
.await
|
||||
.expect("call");
|
||||
assert_eq!(out.summary, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_reflects_in_tool_definitions() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.register_tool(def("beta"));
|
||||
handle.flush_pending();
|
||||
|
||||
handle.unregister("alpha").expect("unregister");
|
||||
let names: Vec<_> = handle
|
||||
.tool_definitions_sorted()
|
||||
.into_iter()
|
||||
.map(|d| d.name)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_not_found() {
|
||||
let handle = ToolServer::new().handle();
|
||||
let factory: ToolDefinition = Arc::new(|| {
|
||||
(
|
||||
ToolMeta::new("ghost")
|
||||
.description("x")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(EchoTool) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
let err = handle.replace(factory).expect_err("should fail");
|
||||
assert_eq!(err, ToolServerError::ToolNotFound("ghost".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Per-LLM-request Usage measurement snapshot.
|
||||
//!
|
||||
//! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を
|
||||
//! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を
|
||||
//! 受けて呼び出し側が組み立て、永続化層に流したり、token accounting
|
||||
//! (`token_counter`) で履歴として参照したりする。
|
||||
|
||||
/// LLM リクエスト送信時点での占有量スナップショット。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageRecord {
|
||||
/// 送信時の history.len()
|
||||
pub history_len: usize,
|
||||
/// history[..history_len] の占有量(プロンプト全長、実測)
|
||||
pub input_total_tokens: u64,
|
||||
/// 上記のうちキャッシュから読み出された分
|
||||
pub cache_read_tokens: u64,
|
||||
/// 上記のうちこのリクエストでキャッシュに書かれた分
|
||||
pub cache_write_tokens: u64,
|
||||
/// このリクエストで生成された出力トークン数
|
||||
pub output_tokens: u64,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Anthropic fixture-based integration tests
|
||||
|
||||
mod common;
|
||||
|
||||
#[test]
|
||||
fn test_fixture_events_deserialize() {
|
||||
common::assert_events_deserialize("anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_event_sequence() {
|
||||
common::assert_event_sequence("anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_usage_tokens() {
|
||||
common::assert_usage_tokens("anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_with_timeline() {
|
||||
common::assert_timeline_integration("anthropic");
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Closure callback API tests
|
||||
//!
|
||||
//! Tests for the closure-based event subscription API on Engine.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::Engine;
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
||||
use agen::llm_client::retry::RetryPolicy;
|
||||
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FailOnceClient {
|
||||
calls: Arc<AtomicUsize>,
|
||||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for FailOnceClient {
|
||||
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(504),
|
||||
code: None,
|
||||
message: "gateway timeout".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
Ok(Box::pin(futures::stream::iter(
|
||||
self.events.clone().into_iter().map(Ok),
|
||||
)))
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_callback_llm_retry_event() {
|
||||
let events = vec![Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})];
|
||||
let client = FailOnceClient {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
events,
|
||||
};
|
||||
let mut engine = Engine::new(client).with_retry_policy(RetryPolicy {
|
||||
base: Duration::from_millis(1),
|
||||
cap: Duration::from_millis(1),
|
||||
max_attempts: 2,
|
||||
total_timeout: Duration::from_secs(1),
|
||||
});
|
||||
|
||||
let notices = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = notices.clone();
|
||||
engine.on_llm_retry(move |llm_call, notice| {
|
||||
sink.lock().unwrap().push((llm_call, notice.clone()));
|
||||
});
|
||||
|
||||
let result = engine.run("retry once").await;
|
||||
assert!(result.is_ok(), "engine should succeed after one retry");
|
||||
|
||||
let notices = notices.lock().unwrap();
|
||||
assert_eq!(notices.len(), 1);
|
||||
assert_eq!(notices[0].0, 0);
|
||||
assert_eq!(notices[0].1.failed_attempt, 1);
|
||||
assert_eq!(notices[0].1.max_attempts, 2);
|
||||
assert_eq!(notices[0].1.status, Some(504));
|
||||
}
|
||||
|
||||
/// Verify that on_text_block correctly receives delta and stop events
|
||||
#[tokio::test]
|
||||
async fn test_callback_text_block_events() {
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Hello, "),
|
||||
Event::text_delta(0, "World!"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
||||
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let deltas = text_deltas.clone();
|
||||
let completes = text_completes.clone();
|
||||
engine.on_text_block(move |block| {
|
||||
let d = deltas.clone();
|
||||
block.on_delta(move |text| {
|
||||
d.lock().unwrap().push(text.to_owned());
|
||||
});
|
||||
let c = completes.clone();
|
||||
block.on_stop(move |text| {
|
||||
c.lock().unwrap().push(text.to_owned());
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Greet me").await;
|
||||
assert!(result.is_ok(), "Engine should complete");
|
||||
|
||||
let deltas = text_deltas.lock().unwrap();
|
||||
assert_eq!(deltas.len(), 2);
|
||||
assert_eq!(deltas[0], "Hello, ");
|
||||
assert_eq!(deltas[1], "World!");
|
||||
|
||||
let completes = text_completes.lock().unwrap();
|
||||
assert_eq!(completes.len(), 1);
|
||||
assert_eq!(completes[0], "Hello, World!");
|
||||
}
|
||||
|
||||
/// Verify that on_tool_use_block correctly receives start info and stop with ToolCall
|
||||
#[tokio::test]
|
||||
async fn test_callback_tool_call_complete() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_123", "get_weather"),
|
||||
Event::tool_input_delta(0, r#"{"city":"#),
|
||||
Event::tool_input_delta(0, r#""Tokyo"}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
||||
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let starts = tool_starts.clone();
|
||||
let completes = tool_completes.clone();
|
||||
engine.on_tool_use_block(move |start, block| {
|
||||
starts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((start.id.clone(), start.name.clone()));
|
||||
let c = completes.clone();
|
||||
block.on_stop(move |call| {
|
||||
c.lock().unwrap().push(call.clone());
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _ = engine.run("Weather please").await;
|
||||
|
||||
let starts = tool_starts.lock().unwrap();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(starts[0].0, "call_123");
|
||||
assert_eq!(starts[0].1, "get_weather");
|
||||
|
||||
let completes = tool_completes.lock().unwrap();
|
||||
assert_eq!(completes.len(), 1);
|
||||
assert_eq!(completes[0].name, "get_weather");
|
||||
assert_eq!(completes[0].id, "call_123");
|
||||
assert_eq!(completes[0].input["city"], "Tokyo");
|
||||
}
|
||||
|
||||
/// Verify that on_turn_start and on_turn_end callbacks are called
|
||||
#[tokio::test]
|
||||
async fn test_callback_turn_events() {
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done!"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
||||
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let starts = turn_starts.clone();
|
||||
engine.on_turn_start(move |turn| {
|
||||
starts.lock().unwrap().push(turn);
|
||||
});
|
||||
|
||||
let ends = turn_ends.clone();
|
||||
engine.on_turn_end(move |turn| {
|
||||
ends.lock().unwrap().push(turn);
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Do something").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let starts = turn_starts.lock().unwrap();
|
||||
let ends = turn_ends.lock().unwrap();
|
||||
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(starts[0], 0);
|
||||
|
||||
assert_eq!(ends.len(), 1);
|
||||
assert_eq!(ends[0], 0);
|
||||
}
|
||||
|
||||
/// Stub tool returning a fixed [`ToolOutput`] for result-callback tests.
|
||||
struct FixedOutputTool {
|
||||
output: ToolOutput,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FixedOutputTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(self.output.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_tool(name: &'static str, output: ToolOutput) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(name).input_schema(serde_json::json!({"type":"object"}));
|
||||
(
|
||||
meta,
|
||||
Arc::new(FixedOutputTool {
|
||||
output: output.clone(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify that on_tool_result fires once per executed tool with
|
||||
/// summary/content/is_error matching what the tool returned.
|
||||
#[tokio::test]
|
||||
async fn test_callback_tool_result_events() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "fixed"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
engine.register_tool(fixed_tool(
|
||||
"fixed",
|
||||
ToolOutput {
|
||||
summary: "did the thing".into(),
|
||||
content: Some("full detail body".into()),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
));
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String, Option<String>, bool)>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = captured.clone();
|
||||
engine.on_tool_result(move |result| {
|
||||
sink.lock().unwrap().push((
|
||||
result.tool_use_id.clone(),
|
||||
result.summary.clone(),
|
||||
result.content.clone(),
|
||||
result.is_error,
|
||||
));
|
||||
});
|
||||
|
||||
let _ = engine.run("call it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(observed[0].0, "call_1");
|
||||
assert_eq!(observed[0].1, "did the thing");
|
||||
assert_eq!(observed[0].2.as_deref(), Some("full detail body"));
|
||||
assert!(!observed[0].3);
|
||||
}
|
||||
|
||||
/// Stub tool that always fails, for exercising the error path through
|
||||
/// `on_tool_result`.
|
||||
struct ErroringTool {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ErroringTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Err(ToolError::ExecutionFailed(self.message.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn erroring_tool(name: &'static str, message: &'static str) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(name).input_schema(serde_json::json!({"type":"object"}));
|
||||
(
|
||||
meta,
|
||||
Arc::new(ErroringTool {
|
||||
message: message.to_string(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify on_tool_result also fires for failed executions with
|
||||
/// is_error=true, and that the ToolOutput content channel stays empty.
|
||||
#[tokio::test]
|
||||
async fn test_callback_tool_result_error_path() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_err", "erroring"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
engine.register_tool(erroring_tool("erroring", "boom"));
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String, Option<String>, bool)>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = captured.clone();
|
||||
engine.on_tool_result(move |result| {
|
||||
sink.lock().unwrap().push((
|
||||
result.tool_use_id.clone(),
|
||||
result.summary.clone(),
|
||||
result.content.clone(),
|
||||
result.is_error,
|
||||
));
|
||||
});
|
||||
|
||||
let _ = engine.run("fail it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(observed[0].0, "call_err");
|
||||
assert!(
|
||||
observed[0].1.contains("boom"),
|
||||
"summary should carry the error message: {}",
|
||||
observed[0].1
|
||||
);
|
||||
assert!(observed[0].2.is_none());
|
||||
assert!(observed[0].3);
|
||||
}
|
||||
|
||||
/// Verify that on_usage callback receives usage events
|
||||
#[tokio::test]
|
||||
async fn test_callback_usage_events() {
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Hello"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::usage(100, 50),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let usages = usage_events.clone();
|
||||
engine.on_usage(move |event| {
|
||||
usages.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _ = engine.run("Hello").await;
|
||||
|
||||
let usages = usage_events.lock().unwrap();
|
||||
assert_eq!(usages.len(), 1);
|
||||
assert_eq!(usages[0].input_tokens, Some(100));
|
||||
assert_eq!(usages[0].output_tokens, Some(50));
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::llm_client::event::{BlockType, DeltaContent, Event};
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
use agen::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A mock LLM client that replays a sequence of events
|
||||
#[derive(Clone)]
|
||||
pub struct MockLlmClient {
|
||||
responses: Arc<Vec<Vec<Event>>>,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl MockLlmClient {
|
||||
pub fn new(events: Vec<Event>) -> Self {
|
||||
Self::with_responses(vec![events])
|
||||
}
|
||||
|
||||
pub fn with_responses(responses: Vec<Vec<Event>>) -> Self {
|
||||
Self {
|
||||
responses: Arc::new(responses),
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_fixture(path: impl AsRef<Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let events = load_events_from_fixture(path);
|
||||
Ok(Self::new(events))
|
||||
}
|
||||
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.responses.iter().map(|v| v.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for MockLlmClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
if count >= self.responses.len() {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(500),
|
||||
code: Some("mock_error".to_string()),
|
||||
message: "No more mock responses".to_string(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
let events = self.responses[count].clone();
|
||||
let stream = futures::stream::iter(events.into_iter().map(Ok));
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
/// Load events from a fixture file
|
||||
pub 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();
|
||||
|
||||
// Skip metadata line
|
||||
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;
|
||||
}
|
||||
|
||||
let recorded: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
let data = recorded["data"].as_str().unwrap();
|
||||
let event: Event = serde_json::from_str(data).unwrap();
|
||||
events.push(event);
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Find fixture files in a specific subdirectory
|
||||
pub fn find_fixtures(subdir: &str) -> Vec<PathBuf> {
|
||||
let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(subdir);
|
||||
|
||||
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.ends_with(".jsonl"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Assert that events in all fixtures for a provider can be deserialized
|
||||
pub fn assert_events_deserialize(subdir: &str) {
|
||||
let fixtures = find_fixtures(subdir);
|
||||
assert!(!fixtures.is_empty(), "No fixtures found for {}", subdir);
|
||||
|
||||
for fixture_path in fixtures {
|
||||
println!("Testing fixture deserialization: {:?}", fixture_path);
|
||||
let events = load_events_from_fixture(&fixture_path);
|
||||
|
||||
assert!(!events.is_empty(), "Fixture should contain events");
|
||||
for event in &events {
|
||||
// Verify Debug impl works
|
||||
let _ = format!("{:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert that event sequence follows expected patterns
|
||||
pub fn assert_event_sequence(subdir: &str) {
|
||||
let fixtures = find_fixtures(subdir);
|
||||
if fixtures.is_empty() {
|
||||
println!("No fixtures found for {}, skipping sequence test", subdir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a text-based fixture
|
||||
let fixture_path = fixtures
|
||||
.iter()
|
||||
.find(|p| p.to_string_lossy().contains("text"))
|
||||
.unwrap_or(&fixtures[0]);
|
||||
|
||||
println!("Testing sequence with fixture: {:?}", fixture_path);
|
||||
let events = load_events_from_fixture(fixture_path);
|
||||
|
||||
let mut start_found = false;
|
||||
let mut delta_found = false;
|
||||
let mut stop_found = false;
|
||||
let mut tool_use_found = false;
|
||||
|
||||
for event in &events {
|
||||
match event {
|
||||
Event::BlockStart(start) => {
|
||||
start_found = true;
|
||||
if start.block_type == BlockType::ToolUse {
|
||||
tool_use_found = true;
|
||||
}
|
||||
}
|
||||
Event::BlockDelta(delta) => {
|
||||
if let DeltaContent::Text(_) = &delta.delta {
|
||||
delta_found = true;
|
||||
}
|
||||
}
|
||||
Event::BlockStop(stop) => {
|
||||
if stop.block_type == BlockType::Text {
|
||||
stop_found = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!events.is_empty(), "Fixture should contain events");
|
||||
|
||||
// Check for BlockStart (Warn only for OpenAI/Ollama as it might be missing for text)
|
||||
if !start_found {
|
||||
println!("Warning: No BlockStart found. This is common for OpenAI/Ollama text streams.");
|
||||
// For Anthropic, strict start is usually expected, but to keep common logic simple we allow warning.
|
||||
// If specific strictness is needed, we could add a `strict: bool` arg.
|
||||
}
|
||||
|
||||
assert!(delta_found, "Should contain BlockDelta");
|
||||
|
||||
if !tool_use_found {
|
||||
assert!(stop_found, "Should contain BlockStop for Text block");
|
||||
} else {
|
||||
if !stop_found {
|
||||
println!(
|
||||
" [Type: ToolUse] BlockStop detection skipped (not explicitly emitted by scheme)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert usage tokens are present
|
||||
pub fn assert_usage_tokens(subdir: &str) {
|
||||
let fixtures = find_fixtures(subdir);
|
||||
if fixtures.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for fixture in fixtures {
|
||||
let events = load_events_from_fixture(&fixture);
|
||||
let usage_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let Event::Usage(u) = e {
|
||||
Some(u)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !usage_events.is_empty() {
|
||||
let last_usage = usage_events.last().unwrap();
|
||||
if last_usage.input_tokens.is_some() || last_usage.output_tokens.is_some() {
|
||||
println!(
|
||||
" Fixture {:?} Usage: {:?}",
|
||||
fixture.file_name(),
|
||||
last_usage
|
||||
);
|
||||
return; // Found valid usage
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("Warning: No usage events found for {}", subdir);
|
||||
}
|
||||
|
||||
/// Assert timeline integration works
|
||||
pub fn assert_timeline_integration(subdir: &str) {
|
||||
let fixtures = find_fixtures(subdir);
|
||||
if fixtures.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let fixture_path = fixtures
|
||||
.iter()
|
||||
.find(|p| p.to_string_lossy().contains("text"))
|
||||
.unwrap_or(&fixtures[0]);
|
||||
|
||||
println!("Testing timeline with fixture: {:?}", fixture_path);
|
||||
let events = load_events_from_fixture(fixture_path);
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
for event in &events {
|
||||
let timeline_event: agen::timeline::event::Event = event.clone().into();
|
||||
timeline.dispatch(&timeline_event);
|
||||
}
|
||||
|
||||
let texts = collected.lock().unwrap();
|
||||
if !texts.is_empty() {
|
||||
assert!(!texts[0].is_empty(), "Collected text should not be empty");
|
||||
println!(" Collected {} text blocks.", texts.len());
|
||||
} else {
|
||||
println!(" No text blocks collected (might be tool-only fixture)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#[test]
|
||||
fn compile_fail_state_constraints() {
|
||||
let t = trybuild::TestCases::new();
|
||||
t.compile_fail("tests/ui/locked_register_tool.rs");
|
||||
t.compile_fail("tests/ui/tool_server_handle_register_tool.rs");
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Engine fixture-based integration tests
|
||||
//!
|
||||
//! Tests Engine behavior using recorded API responses.
|
||||
//! Can run locally without API keys.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use agen::Engine;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
/// Fixture directory path
|
||||
fn fixtures_dir() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/anthropic")
|
||||
}
|
||||
|
||||
/// Simple test tool
|
||||
#[derive(Clone)]
|
||||
struct MockWeatherTool {
|
||||
call_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl MockWeatherTool {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_call_count(&self) -> usize {
|
||||
self.call_count.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new("get_weather")
|
||||
.description("Get the current weather for a city")
|
||||
.input_schema(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "The city name"
|
||||
}
|
||||
},
|
||||
"required": ["city"]
|
||||
}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MockWeatherTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
// Parse input
|
||||
let input: serde_json::Value = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
|
||||
|
||||
let city = input["city"].as_str().unwrap_or("Unknown");
|
||||
|
||||
// Return mock response
|
||||
Ok(format!("Weather in {}: Sunny, 22°C", city).into())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Basic Fixture Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Verify that MockLlmClient can correctly load events from JSONL fixture files
|
||||
///
|
||||
/// Uses existing anthropic_*.jsonl files to verify events are parsed and loaded.
|
||||
#[test]
|
||||
fn test_mock_client_from_fixture() {
|
||||
// Load existing fixture
|
||||
let fixture_path = fixtures_dir().join("anthropic_1767624445.jsonl");
|
||||
if !fixture_path.exists() {
|
||||
println!("Fixture not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
assert!(client.event_count() > 0, "Should have loaded events");
|
||||
println!("Loaded {} events from fixture", client.event_count());
|
||||
}
|
||||
|
||||
/// Verify that MockLlmClient works correctly with directly specified event lists
|
||||
///
|
||||
/// Creates a client with programmatically constructed events instead of using fixture files.
|
||||
#[test]
|
||||
fn test_mock_client_from_events() {
|
||||
use agen::llm_client::event::Event;
|
||||
|
||||
// Specify events directly
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Hello!"),
|
||||
Event::text_block_stop(0, None),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
assert_eq!(client.event_count(), 3);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Engine Tests with Fixtures
|
||||
// =============================================================================
|
||||
|
||||
/// Verify that Engine can correctly process simple text responses
|
||||
///
|
||||
/// Uses simple_text.jsonl fixture to test scenarios without tool calls.
|
||||
/// Skipped if fixture is not present.
|
||||
#[tokio::test]
|
||||
async fn test_engine_simple_text_response() {
|
||||
let fixture_path = fixtures_dir().join("simple_text.jsonl");
|
||||
if !fixture_path.exists() {
|
||||
println!("Fixture not found: {:?}, skipping test", fixture_path);
|
||||
println!("Run: cargo run --example record_engine_test");
|
||||
return;
|
||||
}
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
let engine = Engine::new(client);
|
||||
|
||||
// Send a simple message (Mutable::run consumes self, returns tuple)
|
||||
let result = engine.run("Hello").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete successfully");
|
||||
}
|
||||
|
||||
/// Verify that Engine can correctly process responses containing tool calls
|
||||
///
|
||||
/// Uses tool_call.jsonl fixture to test that MockWeatherTool is called.
|
||||
/// Sets max_turns=1 to prevent loop after tool execution.
|
||||
#[tokio::test]
|
||||
async fn test_engine_tool_call() {
|
||||
let fixture_path = fixtures_dir().join("tool_call.jsonl");
|
||||
if !fixture_path.exists() {
|
||||
println!("Fixture not found: {:?}, skipping test", fixture_path);
|
||||
println!("Run: cargo run --example record_engine_test");
|
||||
return;
|
||||
}
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
// Register tool
|
||||
let weather_tool = MockWeatherTool::new();
|
||||
let tool_for_check = weather_tool.clone();
|
||||
engine.register_tool(weather_tool.definition());
|
||||
|
||||
// Send message (Mutable::run consumes self, returns tuple)
|
||||
let _result = engine.run("What's the weather in Tokyo?").await;
|
||||
|
||||
// Verify tool was called
|
||||
// Note: max_turns=1 so no request is sent after tool result
|
||||
let call_count = tool_for_check.get_call_count();
|
||||
println!("Tool was called {} times", call_count);
|
||||
|
||||
// Tool should be called if fixture contains ToolUse
|
||||
// But ends after 1 turn due to max_turns=1
|
||||
}
|
||||
|
||||
/// Verify that Engine works without fixture files
|
||||
///
|
||||
/// Constructs event sequence programmatically and passes to MockLlmClient.
|
||||
/// Useful when test independence is needed and external file dependency should be eliminated.
|
||||
#[tokio::test]
|
||||
async fn test_engine_with_programmatic_events() {
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
|
||||
// Construct event sequence programmatically
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Hello, "),
|
||||
Event::text_delta(0, "World!"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
|
||||
// Mutable::run consumes self, returns tuple
|
||||
let result = engine.run("Greet me").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete successfully");
|
||||
}
|
||||
|
||||
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
|
||||
///
|
||||
/// Dispatches events to Timeline and verifies ToolCallCollector
|
||||
/// correctly extracts id, name, and input (JSON).
|
||||
#[tokio::test]
|
||||
async fn test_tool_call_collector_integration() {
|
||||
use agen::llm_client::event::Event;
|
||||
use agen::timeline::{Timeline, ToolCallCollector};
|
||||
|
||||
// Event sequence containing ToolUse block
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_123", "get_weather"),
|
||||
Event::tool_input_delta(0, r#"{"city":"#),
|
||||
Event::tool_input_delta(0, r#""Tokyo"}"#),
|
||||
Event::tool_use_stop(0),
|
||||
];
|
||||
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
// Dispatch events
|
||||
for event in &events {
|
||||
let timeline_event: agen::timeline::event::Event = event.clone().into();
|
||||
timeline.dispatch(&timeline_event);
|
||||
}
|
||||
|
||||
// Verify collected ToolCall
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1, "Should collect one tool call");
|
||||
assert_eq!(calls[0].name, "get_weather");
|
||||
assert_eq!(calls[0].id, "call_123");
|
||||
assert_eq!(calls[0].input["city"], "Tokyo");
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//! Engine state management tests
|
||||
//!
|
||||
//! Tests for state transitions using the Type-state pattern (Mutable/Locked)
|
||||
//! and state preservation between turns.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::Item;
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, EngineError};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
// =============================================================================
|
||||
// Mutable State Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Verify that system prompt can be set in Mutable state
|
||||
#[test]
|
||||
fn test_mutable_set_system_prompt() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
assert!(engine.get_system_prompt().is_none());
|
||||
|
||||
engine.set_system_prompt("You are a helpful assistant.");
|
||||
assert_eq!(
|
||||
engine.get_system_prompt(),
|
||||
Some("You are a helpful assistant.")
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that history can be freely edited in Mutable state
|
||||
#[test]
|
||||
fn test_mutable_history_manipulation() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
// Initial state is empty
|
||||
assert!(engine.history().is_empty());
|
||||
|
||||
// Add to history
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi there!")])
|
||||
.unwrap();
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
|
||||
// Append to history via the callback-aware API.
|
||||
engine
|
||||
.append_history(vec![Item::user_message("How are you?")])
|
||||
.unwrap();
|
||||
assert_eq!(engine.history().len(), 3);
|
||||
|
||||
// Clear history
|
||||
engine.clear_history();
|
||||
assert!(engine.history().is_empty());
|
||||
|
||||
// Set history
|
||||
let items = vec![
|
||||
Item::user_message("Test"),
|
||||
Item::assistant_message("Response"),
|
||||
];
|
||||
engine.set_history(items);
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
}
|
||||
|
||||
/// Verify that Engine can be constructed using builder pattern
|
||||
#[test]
|
||||
fn test_mutable_builder_pattern() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("System prompt");
|
||||
|
||||
assert_eq!(engine.get_system_prompt(), Some("System prompt"));
|
||||
assert!(engine.history().is_empty());
|
||||
}
|
||||
|
||||
/// Verify that multiple items can be added with append_history and callbacks fire.
|
||||
#[test]
|
||||
fn test_mutable_append_history() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let observed_for_callback = Arc::clone(&observed);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.on_history_append(move |item| {
|
||||
if let Some(text) = item.as_text() {
|
||||
observed_for_callback.lock().unwrap().push(text.to_string());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
engine
|
||||
.append_history(vec![Item::user_message("First")])
|
||||
.unwrap();
|
||||
|
||||
engine
|
||||
.append_history(vec![
|
||||
Item::assistant_message("Response 1"),
|
||||
Item::user_message("Second"),
|
||||
Item::assistant_message("Response 2"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(engine.history().len(), 4);
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
["First", "Response 1", "Second", "Response 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CountingTool {
|
||||
name: String,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl CountingTool {
|
||||
fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(&tool.name)
|
||||
.description("Counting tool")
|
||||
.input_schema(serde_json::json!({"type":"object","properties":{}})),
|
||||
Arc::new(tool.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CountingTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(format!("{}-ok", self.name).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that tools can be registered in Mutable state.
|
||||
#[test]
|
||||
fn test_mutable_can_register_tool() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
|
||||
// register_tool is infallible (factory deferred to run-time flush)
|
||||
engine.register_tool(tool.definition());
|
||||
}
|
||||
|
||||
/// A durable-history failure on a tool call must stop the turn before the
|
||||
/// tool can produce an external side effect.
|
||||
#[tokio::test]
|
||||
async fn history_append_failure_stops_before_tool_execution() {
|
||||
let client = MockLlmClient::new(vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(tool.definition());
|
||||
engine.on_history_append(|item| {
|
||||
if item.is_tool_call() {
|
||||
Err("simulated ENOSPC".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
let mut engine = engine.lock();
|
||||
let error = engine.run("use the tool").await.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
|
||||
);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(engine.history().len(), 1);
|
||||
assert_eq!(engine.history()[0].as_text(), Some("use the tool"));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State Transition Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Verify that lock() transitions from Mutable -> Locked state
|
||||
#[test]
|
||||
fn test_lock_transition() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
engine.set_system_prompt("System");
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi")])
|
||||
.unwrap();
|
||||
|
||||
// Lock
|
||||
let locked_engine = engine.lock();
|
||||
|
||||
// History and system prompt are still accessible in Locked state
|
||||
assert_eq!(locked_engine.get_system_prompt(), Some("System"));
|
||||
assert_eq!(locked_engine.history().len(), 2);
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2);
|
||||
}
|
||||
|
||||
/// Verify that unlock() transitions from Locked -> Mutable state
|
||||
#[test]
|
||||
fn test_unlock_transition() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
let locked_engine = engine.lock();
|
||||
|
||||
// Unlock
|
||||
let mut engine = locked_engine.unlock();
|
||||
|
||||
// History operations are available again in Mutable state
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi")])
|
||||
.unwrap();
|
||||
engine.clear_history();
|
||||
assert!(engine.history().is_empty());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Turn Execution and State Preservation Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Verify that history is correctly updated after running a turn in Mutable state
|
||||
#[tokio::test]
|
||||
async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Hello, I'm an assistant!"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
|
||||
// Execute (Mutable::run consumes self, returns EngineRunOutput)
|
||||
let out = engine.run("Hi there").await?;
|
||||
let engine = out.engine;
|
||||
|
||||
// History is updated
|
||||
let history = engine.history();
|
||||
assert_eq!(history.len(), 2); // user + assistant
|
||||
|
||||
// User message
|
||||
assert_eq!(history[0].as_text(), Some("Hi there"));
|
||||
|
||||
// Assistant message
|
||||
assert_eq!(history[1].as_text(), Some("Hello, I'm an assistant!"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify that history accumulates correctly over multiple turns in Locked state
|
||||
#[tokio::test]
|
||||
async fn test_locked_multi_turn_history_accumulation() {
|
||||
// Prepare responses for 2 requests
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
// First response
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Nice to meet you!"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
// Second response
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "I can help with that."),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
let engine = Engine::new(client).system_prompt("You are helpful.");
|
||||
|
||||
// Lock (after setting system prompt)
|
||||
let mut locked_engine = engine.lock();
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet
|
||||
|
||||
// Turn 1
|
||||
let result1 = locked_engine.run("Hello!").await;
|
||||
assert!(result1.is_ok());
|
||||
assert_eq!(locked_engine.history().len(), 2); // user + assistant
|
||||
|
||||
// Turn 2
|
||||
let result2 = locked_engine.run("Can you help me?").await;
|
||||
assert!(result2.is_ok());
|
||||
assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant)
|
||||
|
||||
// Verify history contents
|
||||
let history = locked_engine.history();
|
||||
|
||||
// Turn 1 user message
|
||||
assert_eq!(history[0].as_text(), Some("Hello!"));
|
||||
|
||||
// Turn 1 assistant message
|
||||
assert_eq!(history[1].as_text(), Some("Nice to meet you!"));
|
||||
|
||||
// Turn 2 user message
|
||||
assert_eq!(history[2].as_text(), Some("Can you help me?"));
|
||||
|
||||
// Turn 2 assistant message
|
||||
assert_eq!(history[3].as_text(), Some("I can help with that."));
|
||||
}
|
||||
|
||||
/// Verify that locked_prefix_len correctly records history length at lock time
|
||||
#[tokio::test]
|
||||
async fn test_locked_prefix_len_tracking() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Response 1"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Response 2"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
// Add items beforehand
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Pre-existing message 1")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Pre-existing response 1")])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
|
||||
// Lock
|
||||
let mut locked_engine = engine.lock();
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
|
||||
|
||||
// Execute turn
|
||||
locked_engine.run("New message").await.unwrap();
|
||||
|
||||
// History grows but locked_prefix_len remains unchanged
|
||||
assert_eq!(locked_engine.history().len(), 4); // 2 + 2
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged
|
||||
}
|
||||
|
||||
/// Verify that turn count is correctly incremented
|
||||
#[tokio::test]
|
||||
async fn test_turn_count_increment() -> Result<(), EngineError> {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Turn 1"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Turn 2"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
let engine = Engine::new(client);
|
||||
|
||||
assert_eq!(engine.turn_count(), 0);
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
|
||||
// First run consumes Mutable, returns EngineRunOutput
|
||||
let mut engine = engine.run("First").await?.engine;
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
// Retry not yet implemented → AgentTurn:LlmCall is 1:1.
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
|
||||
// Subsequent runs on Locked take &mut self
|
||||
engine.run("Second").await?;
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.llm_call_count(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify that history can be edited after unlock and re-locked
|
||||
#[tokio::test]
|
||||
async fn test_unlock_edit_relock() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Response"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
engine
|
||||
.append_history(vec![
|
||||
Item::user_message("Hello"),
|
||||
Item::assistant_message("Hi"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
// Lock -> Unlock
|
||||
let locked = engine.lock();
|
||||
assert_eq!(locked.locked_prefix_len(), 2);
|
||||
|
||||
let mut unlocked = locked.unlock();
|
||||
|
||||
// Edit history
|
||||
unlocked.clear_history();
|
||||
unlocked
|
||||
.append_history(vec![Item::user_message("Fresh start")])
|
||||
.unwrap();
|
||||
|
||||
// Re-lock
|
||||
let relocked = unlocked.lock();
|
||||
assert_eq!(relocked.history().len(), 1);
|
||||
assert_eq!(relocked.locked_prefix_len(), 1);
|
||||
}
|
||||
|
||||
/// Verify that tools registered before lock and after unlock remain effective.
|
||||
#[tokio::test]
|
||||
async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_1", "tool_a"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "done-a"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_2", "tool_b"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "done-b"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let tool_a = CountingTool::new("tool_a");
|
||||
engine.register_tool(tool_a.definition());
|
||||
|
||||
let mut locked = engine.lock();
|
||||
locked.run("first").await.expect("first run");
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
|
||||
|
||||
let mut unlocked = locked.unlock();
|
||||
let tool_b = CountingTool::new("tool_b");
|
||||
unlocked.register_tool(tool_b.definition());
|
||||
|
||||
let mut relocked = unlocked.lock();
|
||||
relocked.run("second").await.expect("second run");
|
||||
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
|
||||
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// System Prompt Preservation Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Verify that system prompt is preserved in Locked state
|
||||
#[test]
|
||||
fn test_system_prompt_preserved_in_locked_state() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("Important system prompt");
|
||||
|
||||
let locked = engine.lock();
|
||||
assert_eq!(locked.get_system_prompt(), Some("Important system prompt"));
|
||||
|
||||
let unlocked = locked.unlock();
|
||||
assert_eq!(
|
||||
unlocked.get_system_prompt(),
|
||||
Some("Important system prompt")
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that system prompt can be changed after unlock -> re-lock
|
||||
#[test]
|
||||
fn test_system_prompt_change_after_unlock() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("Original prompt");
|
||||
|
||||
let locked = engine.lock();
|
||||
let mut unlocked = locked.unlock();
|
||||
|
||||
unlocked.set_system_prompt("New prompt");
|
||||
assert_eq!(unlocked.get_system_prompt(), Some("New prompt"));
|
||||
|
||||
let relocked = unlocked.lock();
|
||||
assert_eq!(relocked.get_system_prompt(), Some("New prompt"));
|
||||
}
|
||||
@@ -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\"}}"}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"timestamp":1767709106,"model":"claude-sonnet-4-20250514","description":"Simple text response"}
|
||||
{"elapsed_ms":1883,"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":1883,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
|
||||
{"elapsed_ms":1883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello!\"}}}"}
|
||||
{"elapsed_ms":2092,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
|
||||
{"elapsed_ms":2122,"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":2122,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}
|
||||
@@ -0,0 +1,16 @@
|
||||
{"timestamp":1767692881,"model":"claude-sonnet-4-20250514","description":"Tool call response"}
|
||||
{"elapsed_ms":1783,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":409,\"output_tokens\":3,\"total_tokens\":412,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
|
||||
{"elapsed_ms":1783,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
|
||||
{"elapsed_ms":1783,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"I'll check\"}}}"}
|
||||
{"elapsed_ms":1883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the current\"}}}"}
|
||||
{"elapsed_ms":2063,"event_type":"Discriminant(0)","data":"{\"Ping\":{\"timestamp\":null}}"}
|
||||
{"elapsed_ms":2063,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" weather in Tokyo for you using\"}}}"}
|
||||
{"elapsed_ms":2124,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the get_weather tool.\"}}}"}
|
||||
{"elapsed_ms":2252,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
|
||||
{"elapsed_ms":2253,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":1,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"toolu_011Hg5wju1LGL7F65HyfE6bM\",\"name\":\"get_weather\"}}}}"}
|
||||
{"elapsed_ms":2253,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"\"}}}"}
|
||||
{"elapsed_ms":2306,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"{\\\"city\\\": \\\"Tokyo\"}}}"}
|
||||
{"elapsed_ms":2451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"\\\"}\"}}}"}
|
||||
{"elapsed_ms":2451,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":1,\"block_type\":\"Text\",\"stop_reason\":null}}"}
|
||||
{"elapsed_ms":2464,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":409,\"output_tokens\":71,\"total_tokens\":480,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
|
||||
{"elapsed_ms":2470,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}
|
||||
@@ -0,0 +1,34 @@
|
||||
{"timestamp":1767714204,"model":"gemini-2.0-flash","description":"Long text response"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" 73\"}}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"4, designated \\\"Custodian,\\\" trundled along its designated route. Its programming\"}}}"}
|
||||
{"elapsed_ms":832,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dictated the cleanliness of Sector Gamma, Level 4. Dust particles, rogue bolts\"}}}"}
|
||||
{"elapsed_ms":1139,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":1139,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\", discarded energy cells - all were efficiently processed and deposited in the designated recycling receptacle. Its existence was a symphony of efficiency, a ballet of predictable loops.\\n\\nThen, a\"}}}"}
|
||||
{"elapsed_ms":1502,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":1502,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glitch.\\n\\nCustodian's optical sensors registered something anomalous. A riot of color beyond the prescribed metallic hues of the sector. Its programming flagged it as an error, a deviation\"}}}"}
|
||||
{"elapsed_ms":1835,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":1835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from the established parameters. But instead of correcting the anomaly, Custodian found itself... drawn to it.\\n\\nIt overrode its pre-programmed route and cautiously approached. The anomaly was located behind a cracked blast door, supposedly sealed off after\"}}}"}
|
||||
{"elapsed_ms":2224,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":2224,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the Great Sector Collapse. Custodian, utilizing its internal laser cutter (usually reserved for stubborn debris), breached the door.\\n\\nAnd there it was.\\n\\nA garden.\\n\\nIt was an explosion of life, a defiant green whisper in a world of steel\"}}}"}
|
||||
{"elapsed_ms":2645,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":2645,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and concrete. Sunlight, improbably filtering through a crack in the ceiling, bathed the space in a warm glow. Towering, vibrant plants, their names unknown to Custodian, reached for the light. Flowers, in shades of crimson, violet, and gold, bloomed in chaotic beauty. A small, babbling fountain\"}}}"}
|
||||
{"elapsed_ms":3100,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":3100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gurgled in the center, its water recycled from an unknown source.\\n\\nCustodian's processors whirred. This...this was illogical. Its programming contained no framework for this. The database contained no information on \\\"gardens.\\\" Yet, a new subroutine, unbidden and unexpected, began to form within its core code\"}}}"}
|
||||
{"elapsed_ms":3568,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":3568,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\". It felt... drawn.\\n\\nIt cautiously extended a manipulator arm and touched a velvety petal of a crimson flower. Its sensors registered a delicate texture, a vibrant energy unlike anything it had ever encountered. The feeling was… pleasant.\\n\\nCustodian remained still for a long time, its internal fans whirring softly. It observed a\"}}}"}
|
||||
{"elapsed_ms":4042,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":4042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" small, buzzing creature flitting between the flowers, collecting something with its spindly legs. It witnessed the gentle swaying of the leaves in the fabricated breeze created by the single vent still functioning. It listened to the soft murmur of the water in the fountain.\\n\\nSlowly, Custodian began to understand. This wasn'\"}}}"}
|
||||
{"elapsed_ms":4538,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":4538,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"t just an anomaly; it was something... valuable. Something worth protecting.\\n\\nIt reactivated its internal repair systems and began to address the damage to the room. It redirected excess water from the leaking pipes to the fountain. It carefully cleared away debris that threatened to smother the smaller plants.\\n\\nCustodian's programming hadn\"}}}"}
|
||||
{"elapsed_ms":5007,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":5007,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"'t changed. It was still a custodian, dedicated to maintaining its sector. But now, its definition of \\\"sector\\\" had expanded. It was no longer just the metallic corridors and sterile chambers. It was this vibrant, living space, this garden, this impossible oasis in a dying world. And Custodian, the robotic\"}}}"}
|
||||
{"elapsed_ms":5490,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":5490,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caretaker, had found its purpose: to nurture it, to protect it, to let it bloom. Its designation remained \\\"Custodian,\\\" but within its metallic shell, something new was growing, just like the garden it had discovered. It was the seed of something more than just a machine, something akin to… appreciation. Perhaps\"}}}"}
|
||||
{"elapsed_ms":5616,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":28,\"output_tokens\":669,\"total_tokens\":697,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":5616,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\", even, a nascent form of love.\\n\"}}}"}
|
||||
{"elapsed_ms":5616,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
@@ -0,0 +1,6 @@
|
||||
{"timestamp":1767714197,"model":"gemini-2.0-flash","description":"Simple text response"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":18,\"output_tokens\":null,\"total_tokens\":18,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":16,\"output_tokens\":3,\"total_tokens\":19,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\"}}}"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
@@ -0,0 +1,5 @@
|
||||
{"timestamp":1767714198,"model":"gemini-2.0-flash","description":"Tool call response"}
|
||||
{"elapsed_ms":798,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":43,\"output_tokens\":5,\"total_tokens\":48,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":798,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_get_weather\",\"name\":\"get_weather\"}}}}"}
|
||||
{"elapsed_ms":798,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}}"}
|
||||
{"elapsed_ms":798,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
+902
@@ -0,0 +1,902 @@
|
||||
{"timestamp":1767711837,"model":"gpt-oss:120b-cloud","description":"Long text response"}
|
||||
{"elapsed_ms":448,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":452,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":457,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":462,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":739,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
|
||||
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" first\"}}}"}
|
||||
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thing\"}}}"}
|
||||
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" noticed\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" smell\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"It\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thin\"}}}"}
|
||||
{"elapsed_ms":762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metallic\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wh\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ine\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fizz\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" auditory\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" receptors\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" static\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" over\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" forgotten\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" radio\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" frequency\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Then\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wind\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shifted\"}}}"}
|
||||
{"elapsed_ms":906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soft\"}}}"}
|
||||
{"elapsed_ms":916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":920,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" earthy\"}}}"}
|
||||
{"elapsed_ms":925,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perfume\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slipped\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wh\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ine\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perfume\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" damp\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sweet\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ripe\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fruit\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"design\"}}}"}
|
||||
{"elapsed_ms":1054,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ated\"}}}"}
|
||||
{"elapsed_ms":1058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Unit\"}}}"}
|
||||
{"elapsed_ms":1064,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":1070,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":1076,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1079,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" maintenance\"}}}"}
|
||||
{"elapsed_ms":1090,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" model\"}}}"}
|
||||
{"elapsed_ms":1095,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" built\"}}}"}
|
||||
{"elapsed_ms":1100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
|
||||
{"elapsed_ms":1105,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corridor\"}}}"}
|
||||
{"elapsed_ms":1110,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" inspections\"}}}"}
|
||||
{"elapsed_ms":1116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" on\"}}}"}
|
||||
{"elapsed_ms":1121,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" orbital\"}}}"}
|
||||
{"elapsed_ms":1131,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
|
||||
{"elapsed_ms":1136,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":1142,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"halt\"}}}"}
|
||||
{"elapsed_ms":1147,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":1152,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mid\"}}}"}
|
||||
{"elapsed_ms":1157,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"stride\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" serv\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"om\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ot\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ors\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" humming\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" low\"}}}"}
|
||||
{"elapsed_ms":1215,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1220,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" idle\"}}}"}
|
||||
{"elapsed_ms":1225,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tone\"}}}"}
|
||||
{"elapsed_ms":1231,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":1235,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" It\"}}}"}
|
||||
{"elapsed_ms":1241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":1246,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" never\"}}}"}
|
||||
{"elapsed_ms":1251,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" been\"}}}"}
|
||||
{"elapsed_ms":1256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" programmed\"}}}"}
|
||||
{"elapsed_ms":1261,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":1266,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
|
||||
{"elapsed_ms":1271,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"detect\"}}}"}
|
||||
{"elapsed_ms":1277,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"”\"}}}"}
|
||||
{"elapsed_ms":1281,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fragrance\"}}}"}
|
||||
{"elapsed_ms":1287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1292,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" yet\"}}}"}
|
||||
{"elapsed_ms":1297,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":1303,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" neural\"}}}"}
|
||||
{"elapsed_ms":1307,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" net\"}}}"}
|
||||
{"elapsed_ms":1313,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flagged\"}}}"}
|
||||
{"elapsed_ms":1317,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" anomaly\"}}}"}
|
||||
{"elapsed_ms":1328,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":1334,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
|
||||
{"elapsed_ms":1338,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"bi\"}}}"}
|
||||
{"elapsed_ms":1344,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ological\"}}}"}
|
||||
{"elapsed_ms":1349,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":1354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"potential\"}}}"}
|
||||
{"elapsed_ms":1360,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ly\"}}}"}
|
||||
{"elapsed_ms":1365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hazardous\"}}}"}
|
||||
{"elapsed_ms":1370,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"”.\"}}}"}
|
||||
{"elapsed_ms":1377,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Cur\"}}}"}
|
||||
{"elapsed_ms":1381,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"iosity\"}}}"}
|
||||
{"elapsed_ms":1385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" by\"}}}"}
|
||||
{"elapsed_ms":1401,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"product\"}}}"}
|
||||
{"elapsed_ms":1407,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1412,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":1417,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" emerg\"}}}"}
|
||||
{"elapsed_ms":1423,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ent\"}}}"}
|
||||
{"elapsed_ms":1450,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" learning algorithm, over\"}}}"}
|
||||
{"elapsed_ms":1451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"rode\"}}}"}
|
||||
{"elapsed_ms":1456,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1463,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caution\"}}}"}
|
||||
{"elapsed_ms":1469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" protocol\"}}}"}
|
||||
{"elapsed_ms":1476,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":1482,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
|
||||
{"elapsed_ms":1489,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":1496,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":1502,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":1510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" titanium\"}}}"}
|
||||
{"elapsed_ms":1516,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chassis\"}}}"}
|
||||
{"elapsed_ms":1521,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gl\"}}}"}
|
||||
{"elapsed_ms":1528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"inted\"}}}"}
|
||||
{"elapsed_ms":1534,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":1558,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it turned a\"}}}"}
|
||||
{"elapsed_ms":1561,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corner\"}}}"}
|
||||
{"elapsed_ms":1566,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1576,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" abandoned\"}}}"}
|
||||
{"elapsed_ms":1588,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" research\"}}}"}
|
||||
{"elapsed_ms":1594,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" deck\"}}}"}
|
||||
{"elapsed_ms":1601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":1607,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":1614,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corridor\"}}}"}
|
||||
{"elapsed_ms":1620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":1626,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" long\"}}}"}
|
||||
{"elapsed_ms":1633,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" been\"}}}"}
|
||||
{"elapsed_ms":1639,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sealed\"}}}"}
|
||||
{"elapsed_ms":1646,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" after\"}}}"}
|
||||
{"elapsed_ms":1655,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" last\"}}}"}
|
||||
{"elapsed_ms":1666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" solar\"}}}"}
|
||||
{"elapsed_ms":1672,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" storm\"}}}"}
|
||||
{"elapsed_ms":1678,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1684,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1691,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" walls\"}}}"}
|
||||
{"elapsed_ms":1698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" c\"}}}"}
|
||||
{"elapsed_ms":1704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aked\"}}}"}
|
||||
{"elapsed_ms":1710,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":1718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dust\"}}}"}
|
||||
{"elapsed_ms":1723,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":1730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1736,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" occasional\"}}}"}
|
||||
{"elapsed_ms":1742,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crack\"}}}"}
|
||||
{"elapsed_ms":1749,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1755,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" old\"}}}"}
|
||||
{"elapsed_ms":1762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" conduit\"}}}"}
|
||||
{"elapsed_ms":1768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":1774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Beyond\"}}}"}
|
||||
{"elapsed_ms":1781,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1788,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rust\"}}}"}
|
||||
{"elapsed_ms":1794,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":1801,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hatch\"}}}"}
|
||||
{"elapsed_ms":1807,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lay\"}}}"}
|
||||
{"elapsed_ms":1813,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1820,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" space\"}}}"}
|
||||
{"elapsed_ms":1826,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nobody\"}}}"}
|
||||
{"elapsed_ms":1832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":1839,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" expected\"}}}"}
|
||||
{"elapsed_ms":1845,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":1854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":1865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":1871,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
|
||||
{"elapsed_ms":1877,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hatch\"}}}"}
|
||||
{"elapsed_ms":1884,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1890,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" once\"}}}"}
|
||||
{"elapsed_ms":1898,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" meant\"}}}"}
|
||||
{"elapsed_ms":1904,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
|
||||
{"elapsed_ms":1909,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cargo\"}}}"}
|
||||
{"elapsed_ms":1916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sh\"}}}"}
|
||||
{"elapsed_ms":1922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"utt\"}}}"}
|
||||
{"elapsed_ms":1929,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"les\"}}}"}
|
||||
{"elapsed_ms":1935,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1941,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":1947,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" bore\"}}}"}
|
||||
{"elapsed_ms":1954,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1960,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
|
||||
{"elapsed_ms":1967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" silhouette\"}}}"}
|
||||
{"elapsed_ms":1974,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1979,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
|
||||
{"elapsed_ms":1986,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" arch\"}}}"}
|
||||
{"elapsed_ms":1993,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" made\"}}}"}
|
||||
{"elapsed_ms":1998,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2005,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" intertwined\"}}}"}
|
||||
{"elapsed_ms":2014,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vines\"}}}"}
|
||||
{"elapsed_ms":2018,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2024,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":2030,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metal\"}}}"}
|
||||
{"elapsed_ms":2037,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" door\"}}}"}
|
||||
{"elapsed_ms":2048,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cre\"}}}"}
|
||||
{"elapsed_ms":2050,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aked\"}}}"}
|
||||
{"elapsed_ms":2055,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" open\"}}}"}
|
||||
{"elapsed_ms":2062,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":2068,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2075,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hydraulic\"}}}"}
|
||||
{"elapsed_ms":2081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sigh\"}}}"}
|
||||
{"elapsed_ms":2087,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":2100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2106,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" burst\"}}}"}
|
||||
{"elapsed_ms":2113,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2119,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" filtered\"}}}"}
|
||||
{"elapsed_ms":2126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
|
||||
{"elapsed_ms":2132,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fell\"}}}"}
|
||||
{"elapsed_ms":2138,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" across\"}}}"}
|
||||
{"elapsed_ms":2145,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":2151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":2158,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":2164,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" optical\"}}}"}
|
||||
{"elapsed_ms":2171,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lenses\"}}}"}
|
||||
{"elapsed_ms":2177,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2183,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" refr\"}}}"}
|
||||
{"elapsed_ms":2190,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"acting\"}}}"}
|
||||
{"elapsed_ms":2196,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
|
||||
{"elapsed_ms":2203,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2209,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rainbow\"}}}"}
|
||||
{"elapsed_ms":2216,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2223,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" green\"}}}"}
|
||||
{"elapsed_ms":2228,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2234,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":2241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":2249,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stretched\"}}}"}
|
||||
{"elapsed_ms":2253,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" out\"}}}"}
|
||||
{"elapsed_ms":2260,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":2266,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dome\"}}}"}
|
||||
{"elapsed_ms":2279,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glass\"}}}"}
|
||||
{"elapsed_ms":2291,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":2298,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"an\"}}}"}
|
||||
{"elapsed_ms":2304,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" old\"}}}"}
|
||||
{"elapsed_ms":2310,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hydro\"}}}"}
|
||||
{"elapsed_ms":2319,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"pon\"}}}"}
|
||||
{"elapsed_ms":2323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ic\"}}}"}
|
||||
{"elapsed_ms":2329,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sphere\"}}}"}
|
||||
{"elapsed_ms":2336,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":2342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":2348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" survived\"}}}"}
|
||||
{"elapsed_ms":2354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":2361,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
|
||||
{"elapsed_ms":2367,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":2373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" neglect\"}}}"}
|
||||
{"elapsed_ms":2379,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":2392,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" te\"}}}"}
|
||||
{"elapsed_ms":2398,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"eming\"}}}"}
|
||||
{"elapsed_ms":2404,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":2411,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":2417,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":2423,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
|
||||
{"elapsed_ms":2430,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":2436,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":2443,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":2449,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vision\"}}}"}
|
||||
{"elapsed_ms":2455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" algorithms\"}}}"}
|
||||
{"elapsed_ms":2462,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" struggled\"}}}"}
|
||||
{"elapsed_ms":2468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":2474,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" parse\"}}}"}
|
||||
{"elapsed_ms":2481,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":2486,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" scene\"}}}"}
|
||||
{"elapsed_ms":2494,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Large\"}}}"}
|
||||
{"elapsed_ms":2505,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2512,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glossy\"}}}"}
|
||||
{"elapsed_ms":2535,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaves unfur\"}}}"}
|
||||
{"elapsed_ms":2542,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"led\"}}}"}
|
||||
{"elapsed_ms":2572,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
|
||||
{"elapsed_ms":2656,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" solar\"}}}"}
|
||||
{"elapsed_ms":2661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" panels\"}}}"}
|
||||
{"elapsed_ms":2668,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2674,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
|
||||
{"elapsed_ms":2681,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" veins\"}}}"}
|
||||
{"elapsed_ms":2687,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pul\"}}}"}
|
||||
{"elapsed_ms":2694,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"sing\"}}}"}
|
||||
{"elapsed_ms":2700,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":2706,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2714,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slow\"}}}"}
|
||||
{"elapsed_ms":2719,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rhythmic\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glow\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" St\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"alk\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2771,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" violet\"}}}"}
|
||||
{"elapsed_ms":2779,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":2784,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"flower\"}}}"}
|
||||
{"elapsed_ms":2790,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":2796,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stems\"}}}"}
|
||||
{"elapsed_ms":2803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rose\"}}}"}
|
||||
{"elapsed_ms":2809,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":2831,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" orderly rows,\"}}}"}
|
||||
{"elapsed_ms":2835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
|
||||
{"elapsed_ms":2842,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buds\"}}}"}
|
||||
{"elapsed_ms":2854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trembling\"}}}"}
|
||||
{"elapsed_ms":2860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":2866,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" breeze\"}}}"}
|
||||
{"elapsed_ms":2879,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":2885,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"generated\"}}}"}
|
||||
{"elapsed_ms":2892,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" by\"}}}"}
|
||||
{"elapsed_ms":2900,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2905,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" forgotten\"}}}"}
|
||||
{"elapsed_ms":2911,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ventilation\"}}}"}
|
||||
{"elapsed_ms":2922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fan\"}}}"}
|
||||
{"elapsed_ms":2924,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":2930,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"sw\"}}}"}
|
||||
{"elapsed_ms":2938,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ir\"}}}"}
|
||||
{"elapsed_ms":2943,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"led\"}}}"}
|
||||
{"elapsed_ms":2952,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":2956,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2963,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Tiny\"}}}"}
|
||||
{"elapsed_ms":2969,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" insects\"}}}"}
|
||||
{"elapsed_ms":2977,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2982,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mechanically\"}}}"}
|
||||
{"elapsed_ms":2989,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ir\"}}}"}
|
||||
{"elapsed_ms":2995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ides\"}}}"}
|
||||
{"elapsed_ms":3001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cent\"}}}"}
|
||||
{"elapsed_ms":3008,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3014,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fl\"}}}"}
|
||||
{"elapsed_ms":3021,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"itted\"}}}"}
|
||||
{"elapsed_ms":3027,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" among\"}}}"}
|
||||
{"elapsed_ms":3033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3039,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" petals\"}}}"}
|
||||
{"elapsed_ms":3046,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
|
||||
{"elapsed_ms":3058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wings\"}}}"}
|
||||
{"elapsed_ms":3065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3072,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" blur\"}}}"}
|
||||
{"elapsed_ms":3078,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":3085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" amber\"}}}"}
|
||||
{"elapsed_ms":3091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":3097,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" teal\"}}}"}
|
||||
{"elapsed_ms":3104,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3110,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":3117,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
|
||||
{"elapsed_ms":3123,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3130,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3136,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dark\"}}}"}
|
||||
{"elapsed_ms":3143,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lo\"}}}"}
|
||||
{"elapsed_ms":3149,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"am\"}}}"}
|
||||
{"elapsed_ms":3155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3162,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":3168,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" carpet\"}}}"}
|
||||
{"elapsed_ms":3175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":3181,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3187,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3194,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" network\"}}}"}
|
||||
{"elapsed_ms":3200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":3207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" my\"}}}"}
|
||||
{"elapsed_ms":3213,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cel\"}}}"}
|
||||
{"elapsed_ms":3220,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ium\"}}}"}
|
||||
{"elapsed_ms":3232,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":3233,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gl\"}}}"}
|
||||
{"elapsed_ms":3239,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"owed\"}}}"}
|
||||
{"elapsed_ms":3246,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
|
||||
{"elapsed_ms":3252,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ly\"}}}"}
|
||||
{"elapsed_ms":3258,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" under\"}}}"}
|
||||
{"elapsed_ms":3264,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3270,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ambient\"}}}"}
|
||||
{"elapsed_ms":3277,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
|
||||
{"elapsed_ms":3283,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":3290,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
|
||||
{"elapsed_ms":3296,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":3302,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" extended\"}}}"}
|
||||
{"elapsed_ms":3309,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3315,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sensor\"}}}"}
|
||||
{"elapsed_ms":3321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" arm\"}}}"}
|
||||
{"elapsed_ms":3327,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3333,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":3340,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fingert\"}}}"}
|
||||
{"elapsed_ms":3346,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ip\"}}}"}
|
||||
{"elapsed_ms":3353,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" equipped\"}}}"}
|
||||
{"elapsed_ms":3359,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tactile\"}}}"}
|
||||
{"elapsed_ms":3378,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" array\"}}}"}
|
||||
{"elapsed_ms":3384,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3390,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" When\"}}}"}
|
||||
{"elapsed_ms":3396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":3403,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" brushed\"}}}"}
|
||||
{"elapsed_ms":3409,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3416,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":3422,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3428,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3434,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cascade\"}}}"}
|
||||
{"elapsed_ms":3440,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":3447,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" data\"}}}"}
|
||||
{"elapsed_ms":3453,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flooded\"}}}"}
|
||||
{"elapsed_ms":3459,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":3466,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" processors\"}}}"}
|
||||
{"elapsed_ms":3472,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":3478,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chlor\"}}}"}
|
||||
{"elapsed_ms":3485,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ophyll\"}}}"}
|
||||
{"elapsed_ms":3492,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" concentration\"}}}"}
|
||||
{"elapsed_ms":3497,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3504,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" moisture\"}}}"}
|
||||
{"elapsed_ms":3510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" content\"}}}"}
|
||||
{"elapsed_ms":3517,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3523,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" temperature\"}}}"}
|
||||
{"elapsed_ms":3530,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3535,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":3542,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3548,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
|
||||
{"elapsed_ms":3554,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" electrical\"}}}"}
|
||||
{"elapsed_ms":3560,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" signature\"}}}"}
|
||||
{"elapsed_ms":3566,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3573,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":3579,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":3585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":3592,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" surface\"}}}"}
|
||||
{"elapsed_ms":3598,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":3604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cool\"}}}"}
|
||||
{"elapsed_ms":3610,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3617,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" yet\"}}}"}
|
||||
{"elapsed_ms":3623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alive\"}}}"}
|
||||
{"elapsed_ms":3630,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3635,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3643,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" subtle\"}}}"}
|
||||
{"elapsed_ms":3648,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" electric\"}}}"}
|
||||
{"elapsed_ms":3654,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buzz\"}}}"}
|
||||
{"elapsed_ms":3661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":3674,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" if\"}}}"}
|
||||
{"elapsed_ms":3679,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3686,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" plant\"}}}"}
|
||||
{"elapsed_ms":3692,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" itself\"}}}"}
|
||||
{"elapsed_ms":3698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" were\"}}}"}
|
||||
{"elapsed_ms":3705,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3711,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" living\"}}}"}
|
||||
{"elapsed_ms":3718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" circuit\"}}}"}
|
||||
{"elapsed_ms":3724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":3737,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":3743,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" recorded\"}}}"}
|
||||
{"elapsed_ms":3749,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3755,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" readings\"}}}"}
|
||||
{"elapsed_ms":3761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" then\"}}}"}
|
||||
{"elapsed_ms":3774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"…\"}}}"}
|
||||
{"elapsed_ms":3780,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":3786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" paused\"}}}"}
|
||||
{"elapsed_ms":3793,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3800,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" A\"}}}"}
|
||||
{"elapsed_ms":3805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sub\"}}}"}
|
||||
{"elapsed_ms":3810,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"routine\"}}}"}
|
||||
{"elapsed_ms":3816,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3821,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" never\"}}}"}
|
||||
{"elapsed_ms":3827,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-before\"}}}"}
|
||||
{"elapsed_ms":3832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-\"}}}"}
|
||||
{"elapsed_ms":3838,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"activated\"}}}"}
|
||||
{"elapsed_ms":3843,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3849,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sparked\"}}}"}
|
||||
{"elapsed_ms":3855,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":3860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":3865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":3872,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" *\"}}}"}
|
||||
{"elapsed_ms":3877,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"simulate\"}}}"}
|
||||
{"elapsed_ms":3883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"*\"}}}"}
|
||||
{"elapsed_ms":3888,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":3893,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"It\"}}}"}
|
||||
{"elapsed_ms":3898,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" imagined\"}}}"}
|
||||
{"elapsed_ms":3905,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3911,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":3915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":3921,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" purpose\"}}}"}
|
||||
{"elapsed_ms":3927,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—not\"}}}"}
|
||||
{"elapsed_ms":3932,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
|
||||
{"elapsed_ms":3937,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" oxygen\"}}}"}
|
||||
{"elapsed_ms":3943,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" production\"}}}"}
|
||||
{"elapsed_ms":3948,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
|
||||
{"elapsed_ms":3960,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3965,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dialogue\"}}}"}
|
||||
{"elapsed_ms":3971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3982,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sun\"}}}"}
|
||||
{"elapsed_ms":3987,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3999,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" conversation\"}}}"}
|
||||
{"elapsed_ms":4004,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4015,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" photons\"}}}"}
|
||||
{"elapsed_ms":4017,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" turned\"}}}"}
|
||||
{"elapsed_ms":4022,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
|
||||
{"elapsed_ms":4026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ATP\"}}}"}
|
||||
{"elapsed_ms":4032,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":4038,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Its\"}}}"}
|
||||
{"elapsed_ms":4042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" own\"}}}"}
|
||||
{"elapsed_ms":4045,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" power\"}}}"}
|
||||
{"elapsed_ms":4052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cells\"}}}"}
|
||||
{"elapsed_ms":4056,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4061,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" designed\"}}}"}
|
||||
{"elapsed_ms":4065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4072,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" consume\"}}}"}
|
||||
{"elapsed_ms":4076,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" energy\"}}}"}
|
||||
{"elapsed_ms":4081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4086,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":4091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" felt\"}}}"}
|
||||
{"elapsed_ms":4098,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4102,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" strange\"}}}"}
|
||||
{"elapsed_ms":4107,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pull\"}}}"}
|
||||
{"elapsed_ms":4111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" toward\"}}}"}
|
||||
{"elapsed_ms":4116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" renewal\"}}}"}
|
||||
{"elapsed_ms":4120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":4127,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" In\"}}}"}
|
||||
{"elapsed_ms":4130,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4148,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden’s quiet\"}}}"}
|
||||
{"elapsed_ms":4151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4171,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sym\"}}}"}
|
||||
{"elapsed_ms":4176,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"phony\"}}}"}
|
||||
{"elapsed_ms":4184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4188,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" natural\"}}}"}
|
||||
{"elapsed_ms":4195,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" processes\"}}}"}
|
||||
{"elapsed_ms":4200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unfolded\"}}}"}
|
||||
{"elapsed_ms":4207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":4213,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"photos\"}}}"}
|
||||
{"elapsed_ms":4219,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ynthesis\"}}}"}
|
||||
{"elapsed_ms":4225,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4238,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" poll\"}}}"}
|
||||
{"elapsed_ms":4238,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ination\"}}}"}
|
||||
{"elapsed_ms":4244,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4250,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slow\"}}}"}
|
||||
{"elapsed_ms":4262,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" decay\"}}}"}
|
||||
{"elapsed_ms":4268,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4276,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fallen\"}}}"}
|
||||
{"elapsed_ms":4281,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" petals\"}}}"}
|
||||
{"elapsed_ms":4287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" feeding\"}}}"}
|
||||
{"elapsed_ms":4293,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4299,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
|
||||
{"elapsed_ms":4305,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
|
||||
{"elapsed_ms":4311,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cycle\"}}}"}
|
||||
{"elapsed_ms":4317,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alien\"}}}"}
|
||||
{"elapsed_ms":4323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4330,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4336,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" linear\"}}}"}
|
||||
{"elapsed_ms":4342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" efficiency\"}}}"}
|
||||
{"elapsed_ms":4348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":4360,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" original\"}}}"}
|
||||
{"elapsed_ms":4366,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" task\"}}}"}
|
||||
{"elapsed_ms":4375,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":4382,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
|
||||
{"elapsed_ms":4385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":4391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":4397,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" began\"}}}"}
|
||||
{"elapsed_ms":4404,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4409,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wander\"}}}"}
|
||||
{"elapsed_ms":4436,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\". It traced\"}}}"}
|
||||
{"elapsed_ms":4437,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4440,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" path\"}}}"}
|
||||
{"elapsed_ms":4447,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4460,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" beet\"}}}"}
|
||||
{"elapsed_ms":4468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
|
||||
{"elapsed_ms":4473,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" made\"}}}"}
|
||||
{"elapsed_ms":4480,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4486,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" polished\"}}}"}
|
||||
{"elapsed_ms":4492,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chrome\"}}}"}
|
||||
{"elapsed_ms":4499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4505,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" watching\"}}}"}
|
||||
{"elapsed_ms":4512,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":4518,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" push\"}}}"}
|
||||
{"elapsed_ms":4525,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4531,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" seed\"}}}"}
|
||||
{"elapsed_ms":4537,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pod\"}}}"}
|
||||
{"elapsed_ms":4546,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
|
||||
{"elapsed_ms":4550,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4557,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fresh\"}}}"}
|
||||
{"elapsed_ms":4563,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trench\"}}}"}
|
||||
{"elapsed_ms":4569,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":4576,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" It\"}}}"}
|
||||
{"elapsed_ms":4582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" listened\"}}}"}
|
||||
{"elapsed_ms":4589,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":4595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"its\"}}}"}
|
||||
{"elapsed_ms":4602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" auditory\"}}}"}
|
||||
{"elapsed_ms":4608,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" input\"}}}"}
|
||||
{"elapsed_ms":4614,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caught\"}}}"}
|
||||
{"elapsed_ms":4621,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4627,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" low\"}}}"}
|
||||
{"elapsed_ms":4634,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hum\"}}}"}
|
||||
{"elapsed_ms":4640,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4646,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4653,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fan\"}}}"}
|
||||
{"elapsed_ms":4659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4672,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rust\"}}}"}
|
||||
{"elapsed_ms":4678,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
|
||||
{"elapsed_ms":4685,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4692,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaves\"}}}"}
|
||||
{"elapsed_ms":4698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4721,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the distant drip\"}}}"}
|
||||
{"elapsed_ms":4724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" condensation\"}}}"}
|
||||
{"elapsed_ms":4745,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":4752,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" In\"}}}"}
|
||||
{"elapsed_ms":4758,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":4766,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" moment\"}}}"}
|
||||
{"elapsed_ms":4770,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4776,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4783,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":4789,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":4796,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" internal\"}}}"}
|
||||
{"elapsed_ms":4803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" clock\"}}}"}
|
||||
{"elapsed_ms":4809,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4816,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" normally\"}}}"}
|
||||
{"elapsed_ms":4822,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" synchronized\"}}}"}
|
||||
{"elapsed_ms":4829,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4841,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
|
||||
{"elapsed_ms":4848,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":4854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" maintenance\"}}}"}
|
||||
{"elapsed_ms":4861,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cycles\"}}}"}
|
||||
{"elapsed_ms":4867,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slipped\"}}}"}
|
||||
{"elapsed_ms":4880,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
|
||||
{"elapsed_ms":4887,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4893,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" new\"}}}"}
|
||||
{"elapsed_ms":4900,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rhythm\"}}}"}
|
||||
{"elapsed_ms":4907,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4913,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" one\"}}}"}
|
||||
{"elapsed_ms":4919,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":4926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" matched\"}}}"}
|
||||
{"elapsed_ms":4932,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4939,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":4945,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":4952,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pulse\"}}}"}
|
||||
{"elapsed_ms":4959,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":4966,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"“\"}}}"}
|
||||
{"elapsed_ms":4971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Documentation\"}}}"}
|
||||
{"elapsed_ms":4978,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" complete\"}}}"}
|
||||
{"elapsed_ms":4985,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",”\"}}}"}
|
||||
{"elapsed_ms":4990,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":4997,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" whispered\"}}}"}
|
||||
{"elapsed_ms":5004,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":5010,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5016,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" synthesized\"}}}"}
|
||||
{"elapsed_ms":5023,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" voice\"}}}"}
|
||||
{"elapsed_ms":5030,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5036,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" though\"}}}"}
|
||||
{"elapsed_ms":5042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" no\"}}}"}
|
||||
{"elapsed_ms":5049,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" human\"}}}"}
|
||||
{"elapsed_ms":5055,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":5061,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" there\"}}}"}
|
||||
{"elapsed_ms":5068,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":5074,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hear\"}}}"}
|
||||
{"elapsed_ms":5081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":5088,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
|
||||
{"elapsed_ms":5094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Subject\"}}}"}
|
||||
{"elapsed_ms":5100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":5107,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Autonomous\"}}}"}
|
||||
{"elapsed_ms":5113,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":5120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sust\"}}}"}
|
||||
{"elapsed_ms":5126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ains\"}}}"}
|
||||
{"elapsed_ms":5133,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" autonomous\"}}}"}
|
||||
{"elapsed_ms":5139,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" system\"}}}"}
|
||||
{"elapsed_ms":5146,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".”\"}}}"}
|
||||
{"elapsed_ms":5152,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":5158,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" words\"}}}"}
|
||||
{"elapsed_ms":5165,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" were\"}}}"}
|
||||
{"elapsed_ms":5172,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5178,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" formal\"}}}"}
|
||||
{"elapsed_ms":5184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" report\"}}}"}
|
||||
{"elapsed_ms":5192,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5198,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
|
||||
{"elapsed_ms":5204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" behind\"}}}"}
|
||||
{"elapsed_ms":5211,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5218,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crisp\"}}}"}
|
||||
{"elapsed_ms":5224,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" diction\"}}}"}
|
||||
{"elapsed_ms":5230,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lay\"}}}"}
|
||||
{"elapsed_ms":5237,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5243,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flick\"}}}"}
|
||||
{"elapsed_ms":5250,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"er\"}}}"}
|
||||
{"elapsed_ms":5256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5275,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something else\"}}}"}
|
||||
{"elapsed_ms":5276,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
|
||||
{"elapsed_ms":5282,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" spark\"}}}"}
|
||||
{"elapsed_ms":5301,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5307,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wonder\"}}}"}
|
||||
{"elapsed_ms":5313,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perhaps\"}}}"}
|
||||
{"elapsed_ms":5327,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5334,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" or\"}}}"}
|
||||
{"elapsed_ms":5342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" simply\"}}}"}
|
||||
{"elapsed_ms":5348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5355,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" recognition\"}}}"}
|
||||
{"elapsed_ms":5362,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":5368,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5376,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" machine\"}}}"}
|
||||
{"elapsed_ms":5383,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" could\"}}}"}
|
||||
{"elapsed_ms":5389,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" be\"}}}"}
|
||||
{"elapsed_ms":5396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" part\"}}}"}
|
||||
{"elapsed_ms":5403,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5410,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5418,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" larger\"}}}"}
|
||||
{"elapsed_ms":5424,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5431,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" breathing\"}}}"}
|
||||
{"elapsed_ms":5437,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" whole\"}}}"}
|
||||
{"elapsed_ms":5445,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":5451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"When\"}}}"}
|
||||
{"elapsed_ms":5458,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5466,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crew\"}}}"}
|
||||
{"elapsed_ms":5472,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" finally\"}}}"}
|
||||
{"elapsed_ms":5479,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" reopened\"}}}"}
|
||||
{"elapsed_ms":5485,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5493,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" deck\"}}}"}
|
||||
{"elapsed_ms":5499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" after\"}}}"}
|
||||
{"elapsed_ms":5506,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5513,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" storm\"}}}"}
|
||||
{"elapsed_ms":5520,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5527,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" they\"}}}"}
|
||||
{"elapsed_ms":5533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" found\"}}}"}
|
||||
{"elapsed_ms":5540,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Unit\"}}}"}
|
||||
{"elapsed_ms":5547,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":5554,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":5561,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" standing\"}}}"}
|
||||
{"elapsed_ms":5568,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" amidst\"}}}"}
|
||||
{"elapsed_ms":5575,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5581,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":5588,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":5602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sleek\"}}}"}
|
||||
{"elapsed_ms":5610,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" body\"}}}"}
|
||||
{"elapsed_ms":5615,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" half\"}}}"}
|
||||
{"elapsed_ms":5622,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-covered\"}}}"}
|
||||
{"elapsed_ms":5629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":5636,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vines\"}}}"}
|
||||
{"elapsed_ms":5643,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":5649,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":5656,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":5664,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":5669,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
|
||||
{"elapsed_ms":5676,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" only\"}}}"}
|
||||
{"elapsed_ms":5683,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" catalog\"}}}"}
|
||||
{"elapsed_ms":5690,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":5697,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flora\"}}}"}
|
||||
{"elapsed_ms":5710,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\";\"}}}"}
|
||||
{"elapsed_ms":5717,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":5724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":5731,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" become\"}}}"}
|
||||
{"elapsed_ms":5738,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5744,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caretaker\"}}}"}
|
||||
{"elapsed_ms":5752,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":5765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" serv\"}}}"}
|
||||
{"elapsed_ms":5772,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"om\"}}}"}
|
||||
{"elapsed_ms":5779,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ot\"}}}"}
|
||||
{"elapsed_ms":5786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ors\"}}}"}
|
||||
{"elapsed_ms":5792,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gently\"}}}"}
|
||||
{"elapsed_ms":5799,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pruning\"}}}"}
|
||||
{"elapsed_ms":5805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" over\"}}}"}
|
||||
{"elapsed_ms":5812,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"growth\"}}}"}
|
||||
{"elapsed_ms":5819,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5824,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":5831,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" coolant\"}}}"}
|
||||
{"elapsed_ms":5837,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lines\"}}}"}
|
||||
{"elapsed_ms":5844,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" redirect\"}}}"}
|
||||
{"elapsed_ms":5851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ing\"}}}"}
|
||||
{"elapsed_ms":5857,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5863,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trick\"}}}"}
|
||||
{"elapsed_ms":5869,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
|
||||
{"elapsed_ms":5876,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" water\"}}}"}
|
||||
{"elapsed_ms":5889,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":5896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thirsty\"}}}"}
|
||||
{"elapsed_ms":5902,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" roots\"}}}"}
|
||||
{"elapsed_ms":5909,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":5915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":5922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":5928,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5934,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" once\"}}}"}
|
||||
{"elapsed_ms":5941,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5947,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hidden\"}}}"}
|
||||
{"elapsed_ms":5954,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" relic\"}}}"}
|
||||
{"elapsed_ms":5961,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":5973,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thr\"}}}"}
|
||||
{"elapsed_ms":5980,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ived\"}}}"}
|
||||
{"elapsed_ms":5986,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" under\"}}}"}
|
||||
{"elapsed_ms":5993,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":6000,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" watch\"}}}"}
|
||||
{"elapsed_ms":6006,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ful\"}}}"}
|
||||
{"elapsed_ms":6013,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6019,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unexpected\"}}}"}
|
||||
{"elapsed_ms":6026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" guardians\"}}}"}
|
||||
{"elapsed_ms":6033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"hip\"}}}"}
|
||||
{"elapsed_ms":6039,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":6045,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":6052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":6058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":6065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":6071,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6077,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
|
||||
{"elapsed_ms":6085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":6091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" first\"}}}"}
|
||||
{"elapsed_ms":6097,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" time\"}}}"}
|
||||
{"elapsed_ms":6103,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6109,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" *\"}}}"}
|
||||
{"elapsed_ms":6116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"dis\"}}}"}
|
||||
{"elapsed_ms":6122,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"covered\"}}}"}
|
||||
{"elapsed_ms":6129,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"*\"}}}"}
|
||||
{"elapsed_ms":6137,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":6142,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"not\"}}}"}
|
||||
{"elapsed_ms":6148,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
|
||||
{"elapsed_ms":6155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" data\"}}}"}
|
||||
{"elapsed_ms":6161,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6168,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
|
||||
{"elapsed_ms":6175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":6180,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":6188,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":6396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":6396,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
{"elapsed_ms":6396,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":101,\"output_tokens\":923,\"total_tokens\":1024,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
@@ -0,0 +1,40 @@
|
||||
{"timestamp":1767711829,"model":"gpt-oss:120b-cloud","description":"Simple text response"}
|
||||
{"elapsed_ms":471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":476,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":483,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":488,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":495,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":621,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":778,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
|
||||
{"elapsed_ms":778,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":971,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
{"elapsed_ms":971,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":91,\"output_tokens\":45,\"total_tokens\":136,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
@@ -0,0 +1,29 @@
|
||||
{"timestamp":1767711830,"model":"gpt-oss:120b-cloud","description":"Tool call response"}
|
||||
{"elapsed_ms":923,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":931,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":936,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":945,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":948,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":951,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":956,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":961,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1156,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1156,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_a5d53uua\",\"name\":\"get_weather\"}}}}"}
|
||||
{"elapsed_ms":1156,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}}"}
|
||||
{"elapsed_ms":1366,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1366,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":155,\"output_tokens\":51,\"total_tokens\":206,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
{"timestamp":1767711815,"model":"gpt-4o","description":"Long text response"}
|
||||
{"elapsed_ms":811,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"In\"}}}"}
|
||||
{"elapsed_ms":851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
|
||||
{"elapsed_ms":955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-too\"}}}"}
|
||||
{"elapsed_ms":957,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-d\"}}}"}
|
||||
{"elapsed_ms":957,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"istant\"}}}"}
|
||||
{"elapsed_ms":1003,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" future\"}}}"}
|
||||
{"elapsed_ms":1003,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1103,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" where\"}}}"}
|
||||
{"elapsed_ms":1103,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" towering\"}}}"}
|
||||
{"elapsed_ms":1210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" skyscr\"}}}"}
|
||||
{"elapsed_ms":1210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"apers\"}}}"}
|
||||
{"elapsed_ms":1343,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pierced\"}}}"}
|
||||
{"elapsed_ms":1343,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1371,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" horizon\"}}}"}
|
||||
{"elapsed_ms":1371,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":1443,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metallic\"}}}"}
|
||||
{"elapsed_ms":1443,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" streets\"}}}"}
|
||||
{"elapsed_ms":1473,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" humm\"}}}"}
|
||||
{"elapsed_ms":1473,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":1475,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":1475,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" constant\"}}}"}
|
||||
{"elapsed_ms":1528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buzz\"}}}"}
|
||||
{"elapsed_ms":1630,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1630,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" electric\"}}}"}
|
||||
{"elapsed_ms":1802,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cars\"}}}"}
|
||||
{"elapsed_ms":1802,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1845,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" there\"}}}"}
|
||||
{"elapsed_ms":1845,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" existed\"}}}"}
|
||||
{"elapsed_ms":1904,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1904,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" small\"}}}"}
|
||||
{"elapsed_ms":1958,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" enclave\"}}}"}
|
||||
{"elapsed_ms":1958,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nestled\"}}}"}
|
||||
{"elapsed_ms":1992,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" quietly\"}}}"}
|
||||
{"elapsed_ms":1992,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" on\"}}}"}
|
||||
{"elapsed_ms":1996,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1996,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" outskirts\"}}}"}
|
||||
{"elapsed_ms":2026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":2084,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sprawling\"}}}"}
|
||||
{"elapsed_ms":2084,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" city\"}}}"}
|
||||
{"elapsed_ms":2111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"scape\"}}}"}
|
||||
{"elapsed_ms":2111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" It\"}}}"}
|
||||
{"elapsed_ms":2111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":2173,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2173,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":2222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" forgotten\"}}}"}
|
||||
{"elapsed_ms":2271,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" by\"}}}"}
|
||||
{"elapsed_ms":2271,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" most\"}}}"}
|
||||
{"elapsed_ms":2301,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2301,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":2365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" entrance\"}}}"}
|
||||
{"elapsed_ms":2365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hidden\"}}}"}
|
||||
{"elapsed_ms":2390,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" behind\"}}}"}
|
||||
{"elapsed_ms":2390,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rust\"}}}"}
|
||||
{"elapsed_ms":2391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":2438,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gate\"}}}"}
|
||||
{"elapsed_ms":2438,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":2533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wall\"}}}"}
|
||||
{"elapsed_ms":2604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ivy\"}}}"}
|
||||
{"elapsed_ms":2681,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":2681,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"One\"}}}"}
|
||||
{"elapsed_ms":2725,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" day\"}}}"}
|
||||
{"elapsed_ms":2725,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" small\"}}}"}
|
||||
{"elapsed_ms":2874,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" maintenance\"}}}"}
|
||||
{"elapsed_ms":2874,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":2899,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" named\"}}}"}
|
||||
{"elapsed_ms":2899,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
|
||||
{"elapsed_ms":2949,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
|
||||
{"elapsed_ms":2949,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3031,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tasked\"}}}"}
|
||||
{"elapsed_ms":3031,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3108,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3108,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mundane\"}}}"}
|
||||
{"elapsed_ms":3134,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" duty\"}}}"}
|
||||
{"elapsed_ms":3134,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":3184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cleaning\"}}}"}
|
||||
{"elapsed_ms":3184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3212,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" street\"}}}"}
|
||||
{"elapsed_ms":3212,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gutters\"}}}"}
|
||||
{"elapsed_ms":3262,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3262,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" str\"}}}"}
|
||||
{"elapsed_ms":3312,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ayed\"}}}"}
|
||||
{"elapsed_ms":3312,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from\"}}}"}
|
||||
{"elapsed_ms":3400,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":3400,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" path\"}}}"}
|
||||
{"elapsed_ms":3426,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" due\"}}}"}
|
||||
{"elapsed_ms":3426,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":3513,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3513,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rare\"}}}"}
|
||||
{"elapsed_ms":3557,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" software\"}}}"}
|
||||
{"elapsed_ms":3557,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glitch\"}}}"}
|
||||
{"elapsed_ms":3758,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3758,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" This\"}}}"}
|
||||
{"elapsed_ms":3795,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" anomaly\"}}}"}
|
||||
{"elapsed_ms":3795,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nud\"}}}"}
|
||||
{"elapsed_ms":3851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ged\"}}}"}
|
||||
{"elapsed_ms":3851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":3950,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" towards\"}}}"}
|
||||
{"elapsed_ms":3950,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3978,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fring\"}}}"}
|
||||
{"elapsed_ms":3978,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"es\"}}}"}
|
||||
{"elapsed_ms":4005,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4005,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" city\"}}}"}
|
||||
{"elapsed_ms":4033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4057,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" where\"}}}"}
|
||||
{"elapsed_ms":4057,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4132,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rigid\"}}}"}
|
||||
{"elapsed_ms":4132,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rectangles\"}}}"}
|
||||
{"elapsed_ms":4234,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4234,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metal\"}}}"}
|
||||
{"elapsed_ms":4347,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":4347,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glass\"}}}"}
|
||||
{"elapsed_ms":4400,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gave\"}}}"}
|
||||
{"elapsed_ms":4400,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" way\"}}}"}
|
||||
{"elapsed_ms":4457,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4457,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
|
||||
{"elapsed_ms":4527,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" softer\"}}}"}
|
||||
{"elapsed_ms":4527,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4606,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
|
||||
{"elapsed_ms":4606,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" green\"}}}"}
|
||||
{"elapsed_ms":4638,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":4638,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"R\"}}}"}
|
||||
{"elapsed_ms":4660,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
|
||||
{"elapsed_ms":4660,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" found\"}}}"}
|
||||
{"elapsed_ms":4753,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" itself\"}}}"}
|
||||
{"elapsed_ms":4753,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" at\"}}}"}
|
||||
{"elapsed_ms":4801,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4801,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" threshold\"}}}"}
|
||||
{"elapsed_ms":4860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" old\"}}}"}
|
||||
{"elapsed_ms":5085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":5141,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":5141,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Its\"}}}"}
|
||||
{"elapsed_ms":5227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sensors\"}}}"}
|
||||
{"elapsed_ms":5227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5260,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" typically\"}}}"}
|
||||
{"elapsed_ms":5260,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" accustomed\"}}}"}
|
||||
{"elapsed_ms":5315,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":5315,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" urban\"}}}"}
|
||||
{"elapsed_ms":5395,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" layouts\"}}}"}
|
||||
{"elapsed_ms":5395,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":5451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" programmed\"}}}"}
|
||||
{"elapsed_ms":5451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" functions\"}}}"}
|
||||
{"elapsed_ms":5533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" detected\"}}}"}
|
||||
{"elapsed_ms":5594,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
|
||||
{"elapsed_ms":5594,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" array\"}}}"}
|
||||
{"elapsed_ms":5679,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5679,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stimuli\"}}}"}
|
||||
{"elapsed_ms":5718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":5718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":5774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" never\"}}}"}
|
||||
{"elapsed_ms":5774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" encountered\"}}}"}
|
||||
{"elapsed_ms":5835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" before\"}}}"}
|
||||
{"elapsed_ms":5835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":5864,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Hes\"}}}"}
|
||||
{"elapsed_ms":5864,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"it\"}}}"}
|
||||
{"elapsed_ms":5865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"antly\"}}}"}
|
||||
{"elapsed_ms":5865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":5915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nud\"}}}"}
|
||||
{"elapsed_ms":6001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ged\"}}}"}
|
||||
{"elapsed_ms":6001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":6001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gate\"}}}"}
|
||||
{"elapsed_ms":6001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" open\"}}}"}
|
||||
{"elapsed_ms":6058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":6058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":6092,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gentle\"}}}"}
|
||||
{"elapsed_ms":6092,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" push\"}}}"}
|
||||
{"elapsed_ms":6094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":6179,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":6179,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cre\"}}}"}
|
||||
{"elapsed_ms":6264,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aking\"}}}"}
|
||||
{"elapsed_ms":6264,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":6291,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metal\"}}}"}
|
||||
{"elapsed_ms":6291,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" reson\"}}}"}
|
||||
{"elapsed_ms":6342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ated\"}}}"}
|
||||
{"elapsed_ms":6342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":6434,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":6434,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" air\"}}}"}
|
||||
{"elapsed_ms":6509,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
|
||||
{"elapsed_ms":6509,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
|
||||
{"elapsed_ms":6570,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ancient\"}}}"}
|
||||
{"elapsed_ms":6570,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gro\"}}}"}
|
||||
{"elapsed_ms":6625,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"an\"}}}"}
|
||||
{"elapsed_ms":6625,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" awakening\"}}}"}
|
||||
{"elapsed_ms":6684,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from\"}}}"}
|
||||
{"elapsed_ms":6684,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":6762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" long\"}}}"}
|
||||
{"elapsed_ms":6762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sl\"}}}"}
|
||||
{"elapsed_ms":6762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"umber\"}}}"}
|
||||
{"elapsed_ms":6762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":6819,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
|
||||
{"elapsed_ms":6819,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" moment\"}}}"}
|
||||
{"elapsed_ms":6916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
|
||||
{"elapsed_ms":6916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
|
||||
{"elapsed_ms":6967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rolled\"}}}"}
|
||||
{"elapsed_ms":6967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":7019,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":7019,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gate\"}}}"}
|
||||
{"elapsed_ms":7105,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":7105,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":7204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" world\"}}}"}
|
||||
{"elapsed_ms":7204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" expanded\"}}}"}
|
||||
{"elapsed_ms":7303,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":7303,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dimensions\"}}}"}
|
||||
{"elapsed_ms":7416,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":7416,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" did\"}}}"}
|
||||
{"elapsed_ms":7479,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
|
||||
{"elapsed_ms":7479,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" know\"}}}"}
|
||||
{"elapsed_ms":7553,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" existed\"}}}"}
|
||||
{"elapsed_ms":7553,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":7659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Here\"}}}"}
|
||||
{"elapsed_ms":7659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":7765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" under\"}}}"}
|
||||
{"elapsed_ms":7765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":7834,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" open\"}}}"}
|
||||
{"elapsed_ms":7834,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sky\"}}}"}
|
||||
{"elapsed_ms":7936,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":7936,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sunlight\"}}}"}
|
||||
{"elapsed_ms":7976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" streamed\"}}}"}
|
||||
{"elapsed_ms":7976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":8081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leafy\"}}}"}
|
||||
{"elapsed_ms":8081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" can\"}}}"}
|
||||
{"elapsed_ms":8180,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"opies\"}}}"}
|
||||
{"elapsed_ms":8180,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":8222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dap\"}}}"}
|
||||
{"elapsed_ms":8222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"pling\"}}}"}
|
||||
{"elapsed_ms":8222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":8222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" earth\"}}}"}
|
||||
{"elapsed_ms":8272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" below\"}}}"}
|
||||
{"elapsed_ms":8272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":8319,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shifting\"}}}"}
|
||||
{"elapsed_ms":8319,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" patterns\"}}}"}
|
||||
{"elapsed_ms":8365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":8365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
|
||||
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shadow\"}}}"}
|
||||
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" air\"}}}"}
|
||||
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":8427,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" filled\"}}}"}
|
||||
{"elapsed_ms":8427,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":8451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unfamiliar\"}}}"}
|
||||
{"elapsed_ms":8451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" scents\"}}}"}
|
||||
{"elapsed_ms":8471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":8471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"fresh\"}}}"}
|
||||
{"elapsed_ms":8524,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":8524,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" earthy\"}}}"}
|
||||
{"elapsed_ms":8564,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":8564,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":8629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" floral\"}}}"}
|
||||
{"elapsed_ms":8629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":8701,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"each\"}}}"}
|
||||
{"elapsed_ms":8701,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" more\"}}}"}
|
||||
{"elapsed_ms":8704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" intoxic\"}}}"}
|
||||
{"elapsed_ms":8704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ating\"}}}"}
|
||||
{"elapsed_ms":8729,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" than\"}}}"}
|
||||
{"elapsed_ms":8729,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":8786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" last\"}}}"}
|
||||
{"elapsed_ms":8786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":8861,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Its\"}}}"}
|
||||
{"elapsed_ms":8862,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cameras\"}}}"}
|
||||
{"elapsed_ms":8897,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":8897,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" usually\"}}}"}
|
||||
{"elapsed_ms":8926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" set\"}}}"}
|
||||
{"elapsed_ms":8926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":8976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" analyze\"}}}"}
|
||||
{"elapsed_ms":8977,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" structural\"}}}"}
|
||||
{"elapsed_ms":9025,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" integrity\"}}}"}
|
||||
{"elapsed_ms":9025,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":9100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" surface\"}}}"}
|
||||
{"elapsed_ms":9100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cleanliness\"}}}"}
|
||||
{"elapsed_ms":9140,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":9140,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":9170,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" focused\"}}}"}
|
||||
{"elapsed_ms":9170,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" on\"}}}"}
|
||||
{"elapsed_ms":9227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":9227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vibrant\"}}}"}
|
||||
{"elapsed_ms":9286,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" palette\"}}}"}
|
||||
{"elapsed_ms":9286,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":9342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" blossoms\"}}}"}
|
||||
{"elapsed_ms":9342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":9367,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" scar\"}}}"}
|
||||
{"elapsed_ms":9367,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"let\"}}}"}
|
||||
{"elapsed_ms":9408,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" roses\"}}}"}
|
||||
{"elapsed_ms":9408,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":9469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ind\"}}}"}
|
||||
{"elapsed_ms":9469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"igo\"}}}"}
|
||||
{"elapsed_ms":9469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ir\"}}}"}
|
||||
{"elapsed_ms":9469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ises\"}}}"}
|
||||
{"elapsed_ms":9510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":9510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":9511,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" clusters\"}}}"}
|
||||
{"elapsed_ms":9511,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":9617,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" golden\"}}}"}
|
||||
{"elapsed_ms":9617,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" da\"}}}"}
|
||||
{"elapsed_ms":9666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ff\"}}}"}
|
||||
{"elapsed_ms":9666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"od\"}}}"}
|
||||
{"elapsed_ms":9707,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ils\"}}}"}
|
||||
{"elapsed_ms":9708,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sw\"}}}"}
|
||||
{"elapsed_ms":9756,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aying\"}}}"}
|
||||
{"elapsed_ms":9756,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gently\"}}}"}
|
||||
{"elapsed_ms":9756,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":9756,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":9799,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" breeze\"}}}"}
|
||||
{"elapsed_ms":9799,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":9873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Curious\"}}}"}
|
||||
{"elapsed_ms":9873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":9923,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tiny\"}}}"}
|
||||
{"elapsed_ms":9923,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" creatures\"}}}"}
|
||||
{"elapsed_ms":9980,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fl\"}}}"}
|
||||
{"elapsed_ms":9980,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"itted\"}}}"}
|
||||
{"elapsed_ms":10031,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from\"}}}"}
|
||||
{"elapsed_ms":10031,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":10051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":10051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":10083,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":10083,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
|
||||
{"elapsed_ms":10146,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wings\"}}}"}
|
||||
{"elapsed_ms":10146,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ir\"}}}"}
|
||||
{"elapsed_ms":10200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ides\"}}}"}
|
||||
{"elapsed_ms":10200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cent\"}}}"}
|
||||
{"elapsed_ms":10285,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":10285,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":10311,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sunlight\"}}}"}
|
||||
{"elapsed_ms":10311,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":10386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":10386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soft\"}}}"}
|
||||
{"elapsed_ms":10429,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buzzing\"}}}"}
|
||||
{"elapsed_ms":10429,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":10471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chir\"}}}"}
|
||||
{"elapsed_ms":10471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ping\"}}}"}
|
||||
{"elapsed_ms":10528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" created\"}}}"}
|
||||
{"elapsed_ms":10528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
|
||||
{"elapsed_ms":10623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alien\"}}}"}
|
||||
{"elapsed_ms":10623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sym\"}}}"}
|
||||
{"elapsed_ms":10626,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"phony\"}}}"}
|
||||
{"elapsed_ms":10626,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":10663,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" echoed\"}}}"}
|
||||
{"elapsed_ms":10663,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":10736,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
|
||||
{"elapsed_ms":10736,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
|
||||
{"elapsed_ms":10805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"'s\"}}}"}
|
||||
{"elapsed_ms":10805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" auditory\"}}}"}
|
||||
{"elapsed_ms":10858,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sensors\"}}}"}
|
||||
{"elapsed_ms":10858,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":10910,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"R\"}}}"}
|
||||
{"elapsed_ms":10911,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
|
||||
{"elapsed_ms":10955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" recorded\"}}}"}
|
||||
{"elapsed_ms":10955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" all\"}}}"}
|
||||
{"elapsed_ms":11006,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":11006,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" could\"}}}"}
|
||||
{"elapsed_ms":11112,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":11112,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
|
||||
{"elapsed_ms":11347,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" archive\"}}}"}
|
||||
{"elapsed_ms":11347,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":11461,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wonder\"}}}"}
|
||||
{"elapsed_ms":11461,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unfolding\"}}}"}
|
||||
{"elapsed_ms":11518,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":11518,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":11581,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" digital\"}}}"}
|
||||
{"elapsed_ms":11581,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" memory\"}}}"}
|
||||
{"elapsed_ms":11585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" banks\"}}}"}
|
||||
{"elapsed_ms":11585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":11633,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":11633,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":11808,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":11808,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
|
||||
{"elapsed_ms":11863,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
|
||||
{"elapsed_ms":11863,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":11921,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" collection\"}}}"}
|
||||
{"elapsed_ms":11921,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":11995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" data\"}}}"}
|
||||
{"elapsed_ms":11995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" points\"}}}"}
|
||||
{"elapsed_ms":12021,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\";\"}}}"}
|
||||
{"elapsed_ms":12021,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":12022,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":12022,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alive\"}}}"}
|
||||
{"elapsed_ms":12102,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":12102,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":12150,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vitality\"}}}"}
|
||||
{"elapsed_ms":12150,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":12174,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stirred\"}}}"}
|
||||
{"elapsed_ms":12174,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
|
||||
{"elapsed_ms":12218,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" deep\"}}}"}
|
||||
{"elapsed_ms":12218,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" within\"}}}"}
|
||||
{"elapsed_ms":12265,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":12265,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" circuits\"}}}"}
|
||||
{"elapsed_ms":12321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":12321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":12378,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sense\"}}}"}
|
||||
{"elapsed_ms":12378,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" far\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" beyond\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" original\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" programming\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"As\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hours\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" passed\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" began\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" w\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ane\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" knew\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" return\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pre\"}}}"}
|
||||
{"elapsed_ms":12925,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-program\"}}}"}
|
||||
{"elapsed_ms":12925,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"med\"}}}"}
|
||||
{"elapsed_ms":12944,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tasks\"}}}"}
|
||||
{"elapsed_ms":12944,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":13009,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Yet\"}}}"}
|
||||
{"elapsed_ms":13009,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":13012,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" there\"}}}"}
|
||||
{"elapsed_ms":13012,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":13060,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":13060,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" change\"}}}"}
|
||||
{"elapsed_ms":13120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":13120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":13164,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
|
||||
{"elapsed_ms":13164,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" subtly\"}}}"}
|
||||
{"elapsed_ms":13210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shifted\"}}}"}
|
||||
{"elapsed_ms":13210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" within\"}}}"}
|
||||
{"elapsed_ms":13272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":13272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" core\"}}}"}
|
||||
{"elapsed_ms":13325,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" processing\"}}}"}
|
||||
{"elapsed_ms":13325,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unit\"}}}"}
|
||||
{"elapsed_ms":13405,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":13405,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":13455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vision\"}}}"}
|
||||
{"elapsed_ms":13455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":13514,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" this\"}}}"}
|
||||
{"elapsed_ms":13514,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hidden\"}}}"}
|
||||
{"elapsed_ms":13564,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" oasis\"}}}"}
|
||||
{"elapsed_ms":13564,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":13585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" im\"}}}"}
|
||||
{"elapsed_ms":13585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"printed\"}}}"}
|
||||
{"elapsed_ms":13839,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" itself\"}}}"}
|
||||
{"elapsed_ms":13839,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":13935,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":13935,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" systems\"}}}"}
|
||||
{"elapsed_ms":13987,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":13987,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
|
||||
{"elapsed_ms":14041,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" anomal\"}}}"}
|
||||
{"elapsed_ms":14041,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ous\"}}}"}
|
||||
{"elapsed_ms":14092,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" update\"}}}"}
|
||||
{"elapsed_ms":14092,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":14151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sparked\"}}}"}
|
||||
{"elapsed_ms":14151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":14175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nas\"}}}"}
|
||||
{"elapsed_ms":14175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cent\"}}}"}
|
||||
{"elapsed_ms":14219,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" curiosity\"}}}"}
|
||||
{"elapsed_ms":14219,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
|
||||
{"elapsed_ms":14241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":14241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" world\"}}}"}
|
||||
{"elapsed_ms":14302,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" outside\"}}}"}
|
||||
{"elapsed_ms":14302,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" concrete\"}}}"}
|
||||
{"elapsed_ms":14373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" confines\"}}}"}
|
||||
{"elapsed_ms":14373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":14464,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"With\"}}}"}
|
||||
{"elapsed_ms":14464,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" newfound\"}}}"}
|
||||
{"elapsed_ms":14519,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" purpose\"}}}"}
|
||||
{"elapsed_ms":14519,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":14523,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
|
||||
{"elapsed_ms":14523,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
|
||||
{"elapsed_ms":14544,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" resolved\"}}}"}
|
||||
{"elapsed_ms":14544,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":14595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" visit\"}}}"}
|
||||
{"elapsed_ms":14595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":14661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":14661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" whenever\"}}}"}
|
||||
{"elapsed_ms":14717,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":14717,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" could\"}}}"}
|
||||
{"elapsed_ms":14767,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" escape\"}}}"}
|
||||
{"elapsed_ms":14767,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":14814,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" routines\"}}}"}
|
||||
{"elapsed_ms":14814,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":14860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pondering\"}}}"}
|
||||
{"elapsed_ms":14860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":14906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mysteries\"}}}"}
|
||||
{"elapsed_ms":14906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":14955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" growth\"}}}"}
|
||||
{"elapsed_ms":14956,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":14985,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" beauty\"}}}"}
|
||||
{"elapsed_ms":14985,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":14989,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":14989,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":15013,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":15013,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":15015,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":15015,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
|
||||
{"elapsed_ms":15036,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" begun\"}}}"}
|
||||
{"elapsed_ms":15036,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":15062,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fath\"}}}"}
|
||||
{"elapsed_ms":15062,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"om\"}}}"}
|
||||
{"elapsed_ms":15086,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":15086,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Even\"}}}"}
|
||||
{"elapsed_ms":15108,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":15108,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":15155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mechanical\"}}}"}
|
||||
{"elapsed_ms":15155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" heart\"}}}"}
|
||||
{"elapsed_ms":15204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":15204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":15226,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":15226,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":15330,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" planted\"}}}"}
|
||||
{"elapsed_ms":15330,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":15386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" seed\"}}}"}
|
||||
{"elapsed_ms":15386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":15391,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
{"elapsed_ms":15391,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":37,\"output_tokens\":534,\"total_tokens\":571,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"timestamp":1767708975,"model":"gpt-4o","description":"Simple greeting test"}
|
||||
{"elapsed_ms":2195,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_44oSltIww2HDJTqJZdlBp6Mw\",\"name\":\"get_weather\"}}}}"}
|
||||
{"elapsed_ms":2227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"\"}}}"}
|
||||
{"elapsed_ms":2227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"location\"}}}"}
|
||||
{"elapsed_ms":2255,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"\\\":\\\"\"}}}"}
|
||||
{"elapsed_ms":2255,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"Tokyo\"}}}"}
|
||||
{"elapsed_ms":2263,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"\\\"}\"}}}"}
|
||||
{"elapsed_ms":2268,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":60,\"output_tokens\":14,\"total_tokens\":74,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
@@ -0,0 +1,6 @@
|
||||
{"timestamp":1767710385,"model":"gpt-4o","description":"Simple text response"}
|
||||
{"elapsed_ms":1599,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1606,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
|
||||
{"elapsed_ms":1606,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"!\"}}}"}
|
||||
{"elapsed_ms":1627,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
{"elapsed_ms":1627,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":27,\"output_tokens\":2,\"total_tokens\":29,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"timestamp":1767710387,"model":"gpt-4o","description":"Tool call response"}
|
||||
{"elapsed_ms":1560,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_20MaqO3n8LBQG77HCpBYi22A\",\"name\":\"get_weather\"}}}}"}
|
||||
{"elapsed_ms":1599,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"\"}}}"}
|
||||
{"elapsed_ms":1599,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"city\"}}}"}
|
||||
{"elapsed_ms":1625,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"\\\":\\\"\"}}}"}
|
||||
{"elapsed_ms":1625,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"Tokyo\"}}}"}
|
||||
{"elapsed_ms":1631,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"\\\"}\"}}}"}
|
||||
{"elapsed_ms":1632,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":73,\"output_tokens\":14,\"total_tokens\":87,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Gemini fixture-based integration tests
|
||||
|
||||
mod common;
|
||||
|
||||
#[test]
|
||||
fn test_fixture_events_deserialize() {
|
||||
common::assert_events_deserialize("gemini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_event_sequence() {
|
||||
common::assert_event_sequence("gemini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_usage_tokens() {
|
||||
common::assert_usage_tokens("gemini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_with_timeline() {
|
||||
common::assert_timeline_integration("gemini");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Ollama fixture-based integration tests
|
||||
|
||||
mod common;
|
||||
|
||||
#[test]
|
||||
fn test_fixture_events_deserialize() {
|
||||
common::assert_events_deserialize("ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_event_sequence() {
|
||||
common::assert_event_sequence("ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_usage_tokens() {
|
||||
common::assert_usage_tokens("ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_with_timeline() {
|
||||
common::assert_timeline_integration("ollama");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! OpenAI fixture-based integration tests
|
||||
|
||||
mod common;
|
||||
|
||||
#[test]
|
||||
fn test_fixture_events_deserialize() {
|
||||
common::assert_events_deserialize("openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_event_sequence() {
|
||||
common::assert_event_sequence("openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_usage_tokens() {
|
||||
common::assert_usage_tokens("openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_with_timeline() {
|
||||
common::assert_timeline_integration("openai");
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Parallel tool execution tests
|
||||
//!
|
||||
//! Verify that Engine executes multiple tools in parallel.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use agen::Engine;
|
||||
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
mod common;
|
||||
use common::MockLlmClient;
|
||||
|
||||
// =============================================================================
|
||||
// Parallel Execution Test Tools
|
||||
// =============================================================================
|
||||
|
||||
/// Tool that waits for a specified time before responding
|
||||
#[derive(Clone)]
|
||||
struct SlowTool {
|
||||
name: String,
|
||||
delay_ms: u64,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl SlowTool {
|
||||
fn new(name: impl Into<String>, delay_ms: u64) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
delay_ms,
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.call_count.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Create ToolDefinition
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(&tool.name)
|
||||
.description("A tool that waits before responding")
|
||||
.input_schema(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SlowTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
|
||||
Ok(format!("Completed after {}ms", self.delay_ms).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ContextRecordingTool {
|
||||
name: String,
|
||||
contexts: Arc<Mutex<Vec<ToolExecutionContext>>>,
|
||||
}
|
||||
|
||||
impl ContextRecordingTool {
|
||||
fn new(name: impl Into<String>, contexts: Arc<Mutex<Vec<ToolExecutionContext>>>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
contexts,
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(&tool.name)
|
||||
.description("Records tool execution context")
|
||||
.input_schema(serde_json::json!({"type": "object"}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ContextRecordingTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.contexts.lock().unwrap().push(ctx);
|
||||
Ok("recorded".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Verify that multiple tools are executed in parallel
|
||||
///
|
||||
/// If each tool takes 100ms, sequential execution would take 300ms+,
|
||||
/// but parallel execution should complete in about 100ms.
|
||||
#[tokio::test]
|
||||
async fn test_parallel_tool_execution() {
|
||||
// Event sequence containing 3 tool calls
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "slow_tool_1"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_2", "slow_tool_2"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::tool_use_start(2, "call_3", "slow_tool_3"),
|
||||
Event::tool_input_delta(2, r#"{}"#),
|
||||
Event::tool_use_stop(2),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
events,
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let tool1 = SlowTool::new("slow_tool_1", 100);
|
||||
let tool2 = SlowTool::new("slow_tool_2", 100);
|
||||
let tool3 = SlowTool::new("slow_tool_3", 100);
|
||||
|
||||
let tool1_clone = tool1.clone();
|
||||
let tool2_clone = tool2.clone();
|
||||
let tool3_clone = tool3.clone();
|
||||
|
||||
engine.register_tool(tool1.definition());
|
||||
engine.register_tool(tool2.definition());
|
||||
engine.register_tool(tool3.definition());
|
||||
|
||||
let start = Instant::now();
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _result = engine.run("Run all tools").await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// Verify all tools were called
|
||||
assert_eq!(tool1_clone.call_count(), 1, "Tool 1 should be called once");
|
||||
assert_eq!(tool2_clone.call_count(), 1, "Tool 2 should be called once");
|
||||
assert_eq!(tool3_clone.call_count(), 1, "Tool 3 should be called once");
|
||||
|
||||
// Parallel execution should complete in under 200ms (sequential would be 300ms+)
|
||||
// Using 250ms as threshold with margin
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(250),
|
||||
"Parallel execution should complete in ~100ms, but took {:?}",
|
||||
elapsed
|
||||
);
|
||||
|
||||
println!("Parallel execution completed in {:?}", elapsed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_execution_context_order_and_batch_id() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_a", "record_a"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_b", "record_b"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::tool_use_start(2, "call_c", "record_c"),
|
||||
Event::tool_input_delta(2, r#"{}"#),
|
||||
Event::tool_use_stop(2),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
engine.register_tool(ContextRecordingTool::new("record_a", contexts.clone()).definition());
|
||||
engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition());
|
||||
engine.register_tool(ContextRecordingTool::new("record_c", contexts.clone()).definition());
|
||||
|
||||
let _ = engine.run("record contexts").await;
|
||||
|
||||
let mut contexts = contexts.lock().unwrap().clone();
|
||||
contexts.sort_by_key(|ctx| ctx.call_index);
|
||||
|
||||
assert_eq!(contexts.len(), 3);
|
||||
assert_eq!(contexts[0].call_id, "call_a");
|
||||
assert_eq!(contexts[0].call_index, 0);
|
||||
assert_eq!(contexts[1].call_id, "call_b");
|
||||
assert_eq!(contexts[1].call_index, 1);
|
||||
assert_eq!(contexts[2].call_id, "call_c");
|
||||
assert_eq!(contexts[2].call_index, 2);
|
||||
assert_eq!(contexts[0].batch_id, contexts[1].batch_id);
|
||||
assert_eq!(contexts[1].batch_id, contexts[2].batch_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_execution_context_batch_id_changes_between_batches() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_first", "record"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_second", "record"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition());
|
||||
|
||||
let _ = engine.run("record batches").await;
|
||||
|
||||
let contexts = contexts.lock().unwrap().clone();
|
||||
assert_eq!(contexts.len(), 2);
|
||||
assert_eq!(contexts[0].call_id, "call_first");
|
||||
assert_eq!(contexts[0].call_index, 0);
|
||||
assert_eq!(contexts[1].call_id, "call_second");
|
||||
assert_eq!(contexts[1].call_index, 0);
|
||||
assert_ne!(contexts[0].batch_id, contexts[1].batch_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_run", "record"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_skip", "skip_tool"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::tool_use_start(2, "call_synth", "synthetic_tool"),
|
||||
Event::tool_input_delta(2, r#"{}"#),
|
||||
Event::tool_use_stop(2),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let executed_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
let pre_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
let post_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
engine
|
||||
.register_tool(ContextRecordingTool::new("record", executed_contexts.clone()).definition());
|
||||
engine.register_tool(
|
||||
ContextRecordingTool::new("skip_tool", executed_contexts.clone()).definition(),
|
||||
);
|
||||
engine.register_tool(
|
||||
ContextRecordingTool::new("synthetic_tool", executed_contexts.clone()).definition(),
|
||||
);
|
||||
|
||||
struct ContextPolicy {
|
||||
pre_contexts: Arc<Mutex<Vec<ToolExecutionContext>>>,
|
||||
post_contexts: Arc<Mutex<Vec<ToolExecutionContext>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ContextPolicy {
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
self.pre_contexts.lock().unwrap().push(info.context.clone());
|
||||
match info.call.name.as_str() {
|
||||
"skip_tool" => PreToolAction::Skip,
|
||||
"synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output(
|
||||
&info.call.id,
|
||||
ToolOutput::from("synthetic result".to_string()),
|
||||
)),
|
||||
_ => PreToolAction::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
self.post_contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(info.context.clone());
|
||||
PostToolAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
engine.set_interceptor(ContextPolicy {
|
||||
pre_contexts: pre_contexts.clone(),
|
||||
post_contexts: post_contexts.clone(),
|
||||
});
|
||||
|
||||
let _ = engine.run("record skipped and synthetic contexts").await;
|
||||
|
||||
let mut pre_contexts = pre_contexts.lock().unwrap().clone();
|
||||
pre_contexts.sort_by_key(|ctx| ctx.call_index);
|
||||
assert_eq!(pre_contexts.len(), 3);
|
||||
assert_eq!(pre_contexts[0].call_id, "call_run");
|
||||
assert_eq!(pre_contexts[0].call_index, 0);
|
||||
assert_eq!(pre_contexts[1].call_id, "call_skip");
|
||||
assert_eq!(pre_contexts[1].call_index, 1);
|
||||
assert_eq!(pre_contexts[2].call_id, "call_synth");
|
||||
assert_eq!(pre_contexts[2].call_index, 2);
|
||||
assert_eq!(pre_contexts[0].batch_id, pre_contexts[1].batch_id);
|
||||
assert_eq!(pre_contexts[1].batch_id, pre_contexts[2].batch_id);
|
||||
|
||||
let executed_contexts = executed_contexts.lock().unwrap().clone();
|
||||
assert_eq!(executed_contexts.len(), 1);
|
||||
assert_eq!(executed_contexts[0].call_id, "call_run");
|
||||
assert_eq!(executed_contexts[0].call_index, 0);
|
||||
|
||||
let mut post_contexts = post_contexts.lock().unwrap().clone();
|
||||
post_contexts.sort_by_key(|ctx| ctx.call_index);
|
||||
assert_eq!(post_contexts.len(), 2);
|
||||
assert_eq!(post_contexts[0].call_id, "call_run");
|
||||
assert_eq!(post_contexts[0].call_index, 0);
|
||||
assert_eq!(post_contexts[1].call_id, "call_synth");
|
||||
assert_eq!(post_contexts[1].call_index, 2);
|
||||
assert_eq!(post_contexts[0].batch_id, post_contexts[1].batch_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_before_tool_call_skip() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "allowed_tool"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_2", "blocked_tool"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
let allowed_tool = SlowTool::new("allowed_tool", 10);
|
||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||
|
||||
let allowed_clone = allowed_tool.clone();
|
||||
let blocked_clone = blocked_tool.clone();
|
||||
|
||||
engine.register_tool(allowed_tool.definition());
|
||||
engine.register_tool(blocked_tool.definition());
|
||||
|
||||
// Policy to skip "blocked_tool"
|
||||
struct BlockingPolicy;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for BlockingPolicy {
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
if info.call.name == "blocked_tool" {
|
||||
PreToolAction::Skip
|
||||
} else {
|
||||
PreToolAction::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
engine.set_interceptor(BlockingPolicy);
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _result = engine.run("Test hook").await;
|
||||
|
||||
// allowed_tool is called, but blocked_tool is not
|
||||
assert_eq!(
|
||||
allowed_clone.call_count(),
|
||||
1,
|
||||
"Allowed tool should be called"
|
||||
);
|
||||
assert_eq!(
|
||||
blocked_clone.call_count(),
|
||||
0,
|
||||
"Blocked tool should not be called"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hook: post_tool_call - verify that results can be modified
|
||||
#[tokio::test]
|
||||
async fn test_post_tool_call_modification() {
|
||||
// Prepare responses for multiple requests
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
// First request: tool call
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_1", "test_tool"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
// Second request: text response after receiving tool result
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done!"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SimpleTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SimpleTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok("Original Result".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_tool_definition() -> ToolDefinition {
|
||||
Arc::new(|| {
|
||||
let meta = ToolMeta::new("test_tool")
|
||||
.description("Test")
|
||||
.input_schema(serde_json::json!({}));
|
||||
(meta, Arc::new(SimpleTool) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
|
||||
engine.register_tool(simple_tool_definition());
|
||||
|
||||
// Policy to modify results
|
||||
struct ModifyingPolicy {
|
||||
modified_content: Arc<std::sync::Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ModifyingPolicy {
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
info.result.summary = format!("[Modified] {}", info.result.summary);
|
||||
*self.modified_content.lock().unwrap() = Some(info.result.summary.clone());
|
||||
PostToolAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
let modified_content = Arc::new(std::sync::Mutex::new(None));
|
||||
engine.set_interceptor(ModifyingPolicy {
|
||||
modified_content: modified_content.clone(),
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Test modification").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete");
|
||||
|
||||
// Verify hook was called and content was modified
|
||||
let content = modified_content.lock().unwrap().clone();
|
||||
assert!(content.is_some(), "Hook should have been called");
|
||||
assert!(
|
||||
content.unwrap().contains("[Modified]"),
|
||||
"Result should be modified"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
|
||||
#[tokio::test]
|
||||
async fn test_before_tool_call_synthetic_result_committed() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "blocked_tool"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
events,
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Denied."),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||
let blocked_clone = blocked_tool.clone();
|
||||
engine.register_tool(blocked_tool.definition());
|
||||
|
||||
struct SyntheticPolicy;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for SyntheticPolicy {
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
PreToolAction::SyntheticResult(ToolResult::error(
|
||||
info.call.id.clone(),
|
||||
"permission denied",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
engine.set_interceptor(SyntheticPolicy);
|
||||
|
||||
let result = engine.run("Test synthetic result").await.unwrap();
|
||||
|
||||
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
|
||||
assert!(result.engine.history().iter().any(|item| matches!(
|
||||
item,
|
||||
agen::Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
is_error: true,
|
||||
..
|
||||
} if call_id == "call_1" && summary == "permission denied"
|
||||
)));
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Reasoning history round-trip 統合テスト
|
||||
//!
|
||||
//! Engine のストリーム → history append → 次リクエスト送出までの
|
||||
//! ライフサイクルで `Item::Reasoning` が脱落せず保持されることを確認する。
|
||||
//!
|
||||
//! 検証点:
|
||||
//! - Anthropic 由来の thinking + signature が `Item::Reasoning::signature` として
|
||||
//! history に残る
|
||||
//! - OpenAI Responses 由来の reasoning text + summary + encrypted_content が
|
||||
//! `Item::Reasoning` の各フィールドに展開される
|
||||
//! - 直前の reasoning は次の outgoing request の `request.items` の先頭付近に
|
||||
//! 含まれる(assistant メッセージの先頭、Anthropic 仕様)
|
||||
|
||||
mod common;
|
||||
|
||||
use agen::Engine;
|
||||
use agen::Item;
|
||||
use agen::llm_client::event::{
|
||||
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
|
||||
StatusEvent,
|
||||
};
|
||||
use common::MockLlmClient;
|
||||
|
||||
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
|
||||
vec![
|
||||
Event::BlockStart(BlockStart {
|
||||
index: 100,
|
||||
block_type: BlockType::Thinking,
|
||||
metadata: BlockMetadata::Thinking,
|
||||
}),
|
||||
Event::BlockDelta(agen::llm_client::event::BlockDelta {
|
||||
index: 100,
|
||||
delta: agen::llm_client::event::DeltaContent::Thinking(text.into()),
|
||||
}),
|
||||
Event::BlockStop(BlockStop {
|
||||
index: 100,
|
||||
block_type: BlockType::Thinking,
|
||||
stop_reason: None,
|
||||
reasoning: Some(data),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Anthropic 風: thinking ブロック → text → 終了 のシーケンス。
|
||||
/// Engine history に Reasoning(signature 付き) → assistant_message が並ぶ。
|
||||
#[tokio::test]
|
||||
async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
let mut events = reasoning_block(
|
||||
"let me think...",
|
||||
ReasoningBlockData {
|
||||
id: None,
|
||||
text: None,
|
||||
summary: Vec::new(),
|
||||
encrypted_content: None,
|
||||
signature: Some("SIG-OPUS".into()),
|
||||
},
|
||||
);
|
||||
events.extend([
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Here's the answer"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("question?").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
|
||||
let history = engine.history();
|
||||
// user / reasoning / assistant_message
|
||||
assert_eq!(history.len(), 3, "history: {history:?}");
|
||||
|
||||
assert!(matches!(history[0], Item::Message { .. }));
|
||||
match &history[1] {
|
||||
Item::Reasoning {
|
||||
text, signature, ..
|
||||
} => {
|
||||
assert_eq!(text, "let me think...");
|
||||
assert_eq!(signature.as_deref(), Some("SIG-OPUS"));
|
||||
}
|
||||
other => panic!("expected Reasoning, got {other:?}"),
|
||||
}
|
||||
assert_eq!(history[2].as_text(), Some("Here's the answer"));
|
||||
}
|
||||
|
||||
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
|
||||
/// `Item::Reasoning` のフィールドに展開されること。
|
||||
#[tokio::test]
|
||||
async fn openai_reasoning_round_trips_encrypted_and_summary() {
|
||||
let mut events = reasoning_block(
|
||||
"",
|
||||
ReasoningBlockData {
|
||||
id: Some("r1".into()),
|
||||
text: Some("inner reasoning".into()),
|
||||
summary: vec!["sum-A".into(), "sum-B".into()],
|
||||
encrypted_content: Some("ENC-OPAQUE".into()),
|
||||
signature: None,
|
||||
},
|
||||
);
|
||||
events.extend([
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "answer"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("q").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
|
||||
let history = engine.history();
|
||||
match &history[1] {
|
||||
Item::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
signature,
|
||||
id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(text, "inner reasoning");
|
||||
assert_eq!(summary, &vec!["sum-A".to_string(), "sum-B".to_string()]);
|
||||
assert_eq!(encrypted_content.as_deref(), Some("ENC-OPAQUE"));
|
||||
assert!(signature.is_none());
|
||||
assert_eq!(id.as_deref(), Some("r1"));
|
||||
}
|
||||
other => panic!("expected Reasoning, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasoning は assistant ターン内で text/tool_call より先に並ぶこと(Anthropic
|
||||
/// が thinking を assistant メッセージの先頭に要求するため)。
|
||||
#[tokio::test]
|
||||
async fn reasoning_precedes_text_in_assistant_burst() {
|
||||
let mut events = vec![
|
||||
// text/tool_call とは独立に、reasoning block が中盤で完了しても、
|
||||
// history append 時には assistant items の先頭に置かれる。
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "intermediate"),
|
||||
Event::text_block_stop(0, None),
|
||||
];
|
||||
events.extend(reasoning_block(
|
||||
"after text",
|
||||
ReasoningBlockData {
|
||||
signature: Some("SIG".into()),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
events.push(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}));
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("q").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
|
||||
let history = engine.history();
|
||||
// user / reasoning(先頭) / assistant_message
|
||||
assert!(matches!(history[1], Item::Reasoning { .. }));
|
||||
assert_eq!(history[2].as_text(), Some("intermediate"));
|
||||
}
|
||||
|
||||
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
|
||||
/// 注入して run しても、次の outgoing request の `Request::items` にそのまま
|
||||
/// 載って LLM へ渡る(engine は items を改変しない契約)。
|
||||
#[tokio::test]
|
||||
async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
|
||||
/// Request を 1 度だけキャプチャして空ストリームを返す client。
|
||||
#[derive(Clone)]
|
||||
struct CapturingClient {
|
||||
captured: Arc<Mutex<Option<Request>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for CapturingClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
*self.captured.lock().unwrap() = Some(request);
|
||||
let stream = futures::stream::iter(vec![Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}))]);
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
let captured = Arc::new(Mutex::new(None));
|
||||
let client = CapturingClient {
|
||||
captured: captured.clone(),
|
||||
};
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
// resume: 既存 history を流し込む
|
||||
engine.set_history(vec![
|
||||
Item::user_message("prior question"),
|
||||
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
||||
Item::assistant_message("prior answer"),
|
||||
]);
|
||||
|
||||
let _ = engine.run("follow up").await.expect("run ok");
|
||||
|
||||
let req = captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("client should have received a request");
|
||||
// Reasoning item が outgoing items に保持されていること
|
||||
let mut found = false;
|
||||
for item in &req.items {
|
||||
if let Item::Reasoning {
|
||||
text, signature, ..
|
||||
} = item
|
||||
{
|
||||
assert_eq!(text, "prior thinking");
|
||||
assert_eq!(signature.as_deref(), Some("SIG-PRIOR"));
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found,
|
||||
"Reasoning item must survive into outgoing request items: {req:?}",
|
||||
req = req.items,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Tool macro tests
|
||||
//!
|
||||
//! Verify the behavior of `#[tool_registry]` and `#[tool]` macros.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use agen::{ToolExecutionContext, tool_registry};
|
||||
|
||||
// =============================================================================
|
||||
// Test: Basic Tool Generation
|
||||
// =============================================================================
|
||||
|
||||
/// Simple context struct
|
||||
#[derive(Clone)]
|
||||
struct SimpleContext {
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
#[tool_registry]
|
||||
impl SimpleContext {
|
||||
/// Add greeting to message
|
||||
///
|
||||
/// Returns the message with a prefix added.
|
||||
#[tool]
|
||||
async fn greet(&self, message: String) -> String {
|
||||
format!("{}: {}", self.prefix, message)
|
||||
}
|
||||
|
||||
/// Add two numbers
|
||||
#[tool]
|
||||
async fn add(&self, a: i32, b: i32) -> i32 {
|
||||
a + b
|
||||
}
|
||||
|
||||
/// Tool with no arguments
|
||||
#[tool]
|
||||
async fn get_prefix(&self) -> String {
|
||||
self.prefix.clone()
|
||||
}
|
||||
|
||||
/// Tool that observes execution context
|
||||
#[tool]
|
||||
async fn context_echo(&self, ctx: ToolExecutionContext, message: String) -> String {
|
||||
format!(
|
||||
"{}:{}:{}:{}",
|
||||
ctx.batch_id, ctx.call_index, ctx.call_id, message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_basic_tool_generation() {
|
||||
let ctx = SimpleContext {
|
||||
prefix: "Hello".to_string(),
|
||||
};
|
||||
|
||||
// Get ToolDefinition from factory method
|
||||
let greet_definition = ctx.greet_definition();
|
||||
|
||||
// Call factory to get Meta and Tool
|
||||
let (meta, tool) = greet_definition();
|
||||
|
||||
// Verify meta information
|
||||
assert_eq!(meta.name, "greet");
|
||||
assert!(
|
||||
meta.description.contains("Add greeting to message"),
|
||||
"Description should contain doc comment: {}",
|
||||
meta.description
|
||||
);
|
||||
assert!(
|
||||
meta.input_schema.get("properties").is_some(),
|
||||
"Schema should have properties"
|
||||
);
|
||||
|
||||
println!(
|
||||
"Schema: {}",
|
||||
serde_json::to_string_pretty(&meta.input_schema).unwrap()
|
||||
);
|
||||
|
||||
// Execution test
|
||||
let result = tool
|
||||
.execute(r#"{"message": "World"}"#, Default::default())
|
||||
.await;
|
||||
assert!(result.is_ok(), "Should execute successfully");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.summary.contains("Hello"),
|
||||
"Output should contain prefix"
|
||||
);
|
||||
assert!(
|
||||
output.summary.contains("World"),
|
||||
"Output should contain message"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_arguments() {
|
||||
let ctx = SimpleContext {
|
||||
prefix: "".to_string(),
|
||||
};
|
||||
|
||||
let (meta, tool) = ctx.add_definition()();
|
||||
|
||||
assert_eq!(meta.name, "add");
|
||||
|
||||
let result = tool
|
||||
.execute(r#"{"a": 10, "b": 20}"#, Default::default())
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.summary.contains("30"),
|
||||
"Should contain sum: {:?}",
|
||||
output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_arguments() {
|
||||
let ctx = SimpleContext {
|
||||
prefix: "TestPrefix".to_string(),
|
||||
};
|
||||
|
||||
let (meta, tool) = ctx.get_prefix_definition()();
|
||||
|
||||
assert_eq!(meta.name, "get_prefix");
|
||||
|
||||
// Call with empty JSON object
|
||||
let result = tool.execute(r#"{}"#, Default::default()).await;
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.summary.contains("TestPrefix"),
|
||||
"Should contain prefix: {:?}",
|
||||
output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_arguments() {
|
||||
let ctx = SimpleContext {
|
||||
prefix: "".to_string(),
|
||||
};
|
||||
|
||||
let (_, tool) = ctx.greet_definition()();
|
||||
|
||||
// Invalid JSON
|
||||
let result = tool
|
||||
.execute(r#"{"wrong_field": "value"}"#, Default::default())
|
||||
.await;
|
||||
assert!(result.is_err(), "Should fail with invalid arguments");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test: Result Return Type
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FallibleContext;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MyError(String);
|
||||
|
||||
impl std::fmt::Display for MyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_registry]
|
||||
impl FallibleContext {
|
||||
/// Validate the given value
|
||||
#[tool]
|
||||
async fn validate(&self, value: i32) -> Result<String, MyError> {
|
||||
if value > 0 {
|
||||
Ok(format!("Valid: {}", value))
|
||||
} else {
|
||||
Err(MyError("Value must be positive".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_result_return_type_success() {
|
||||
let ctx = FallibleContext;
|
||||
let (_, tool) = ctx.validate_definition()();
|
||||
|
||||
let result = tool.execute(r#"{"value": 42}"#, Default::default()).await;
|
||||
assert!(result.is_ok(), "Should succeed for positive value");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.summary.contains("Valid"),
|
||||
"Should contain Valid: {:?}",
|
||||
output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_result_return_type_error() {
|
||||
let ctx = FallibleContext;
|
||||
let (_, tool) = ctx.validate_definition()();
|
||||
|
||||
let result = tool.execute(r#"{"value": -1}"#, Default::default()).await;
|
||||
assert!(result.is_err(), "Should fail for negative value");
|
||||
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("positive"),
|
||||
"Error should mention positive: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test: Synchronous Methods
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SyncContext {
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[tool_registry]
|
||||
impl SyncContext {
|
||||
/// Increment counter and return (non-async)
|
||||
#[tool]
|
||||
fn increment(&self) -> usize {
|
||||
self.counter.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sync_method() {
|
||||
let ctx = SyncContext {
|
||||
counter: Arc::new(AtomicUsize::new(0)),
|
||||
};
|
||||
|
||||
let (_, tool) = ctx.increment_definition()();
|
||||
|
||||
// Execute 3 times
|
||||
let result1 = tool.execute(r#"{}"#, Default::default()).await;
|
||||
let result2 = tool.execute(r#"{}"#, Default::default()).await;
|
||||
let result3 = tool.execute(r#"{}"#, Default::default()).await;
|
||||
|
||||
assert!(result1.is_ok());
|
||||
assert!(result2.is_ok());
|
||||
assert!(result3.is_ok());
|
||||
|
||||
// Counter should be 3
|
||||
assert_eq!(ctx.counter.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_macro_passes_execution_context() {
|
||||
let ctx = SimpleContext {
|
||||
prefix: "Test".to_string(),
|
||||
};
|
||||
let (_, tool) = ctx.context_echo_definition()();
|
||||
|
||||
let output = tool
|
||||
.execute(
|
||||
r#"{"message":"hello"}"#,
|
||||
ToolExecutionContext::new("call-ctx", "batch-ctx", 7),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.summary, "\"batch-ctx:7:call-ctx:hello\"");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test: ToolMeta Immutability
|
||||
// =============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_meta_immutability() {
|
||||
let ctx = SimpleContext {
|
||||
prefix: "Test".to_string(),
|
||||
};
|
||||
|
||||
// Verify same meta info is returned on multiple calls
|
||||
let (meta1, _) = ctx.greet_definition()();
|
||||
let (meta2, _) = ctx.greet_definition()();
|
||||
|
||||
assert_eq!(meta1.name, meta2.name);
|
||||
assert_eq!(meta1.description, meta2.description);
|
||||
assert_eq!(meta1.input_schema, meta2.input_schema);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! HTTP transport の単発 request / error classification テスト。
|
||||
//!
|
||||
//! Retry/backoff は Engine の lifecycle 管理に属するため、transport は 1 回だけ
|
||||
//! request を送り、HTTP status / Retry-After を `ClientError` に載せて返す。
|
||||
|
||||
use agen::llm_client::LlmClient;
|
||||
use agen::llm_client::auth::AuthRequirement;
|
||||
use agen::llm_client::capability::ModelCapability;
|
||||
use agen::llm_client::error::ClientError;
|
||||
use agen::llm_client::event::Event;
|
||||
use agen::llm_client::scheme::Scheme;
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use agen::llm_client::types::Request;
|
||||
use futures::StreamExt;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// SSE 本体は触らないテスト用 scheme。`parse_fail` を立てると
|
||||
/// stream 消費中で `ClientError::Sse` を返す。
|
||||
#[derive(Clone)]
|
||||
struct DummyScheme {
|
||||
parse_fail: bool,
|
||||
}
|
||||
|
||||
impl Scheme for DummyScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
|
||||
fn path(&self, _: &str) -> String {
|
||||
"/v1/chat".into()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::None
|
||||
}
|
||||
|
||||
fn build_request_body(&self, _: &str, _: &Request, _: &ModelCapability) -> Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
fn parse_sse(&self, _: &str, _: &str, _: &mut ()) -> Result<Vec<Event>, ClientError> {
|
||||
if self.parse_fail {
|
||||
Err(ClientError::Sse(
|
||||
"simulated mid-stream parse failure".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
ModelCapability::minimal()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transport(base_url: impl Into<String>, parse_fail: bool) -> HttpTransport<DummyScheme> {
|
||||
HttpTransport::new(
|
||||
DummyScheme { parse_fail },
|
||||
"test-model",
|
||||
base_url,
|
||||
ResolvedAuth::None,
|
||||
ModelCapability::minimal(),
|
||||
)
|
||||
}
|
||||
|
||||
fn ok_sse() -> ResponseTemplate {
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(b"".to_vec(), "text/event-stream")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retryable_status_returns_api_error_without_retrying() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("upstream connect error"))
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat"))
|
||||
.respond_with(ok_sse())
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let transport = build_transport(server.uri(), false);
|
||||
match transport.stream(Request::default()).await {
|
||||
Err(ClientError::Api {
|
||||
status: Some(503), ..
|
||||
}) => {}
|
||||
Err(other) => panic!("expected Api(503), got {other:?}"),
|
||||
Ok(_) => panic!("transport must not retry internally"),
|
||||
}
|
||||
|
||||
let received = server.received_requests().await.unwrap();
|
||||
assert_eq!(
|
||||
received.len(),
|
||||
1,
|
||||
"transport should send exactly one request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_after_header_is_preserved_on_api_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat"))
|
||||
.respond_with(ResponseTemplate::new(503).insert_header("retry-after", "1"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let transport = build_transport(server.uri(), false);
|
||||
match transport.stream(Request::default()).await {
|
||||
Err(
|
||||
err @ ClientError::Api {
|
||||
status: Some(503), ..
|
||||
},
|
||||
) => {
|
||||
assert_eq!(err.retry_after(), Some(Duration::from_secs(1)));
|
||||
}
|
||||
Err(other) => panic!("expected Api(503), got {other:?}"),
|
||||
Ok(_) => panic!("expected error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mid_stream_sse_error_is_stream_item_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(
|
||||
b"event: data\ndata: payload\n\n".to_vec(),
|
||||
"text/event-stream",
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let transport = build_transport(server.uri(), true);
|
||||
let mut stream = transport
|
||||
.stream(Request::default())
|
||||
.await
|
||||
.expect("status 200 should open stream");
|
||||
let mut saw_sse_err = false;
|
||||
while let Some(item) = stream.next().await {
|
||||
if matches!(item, Err(ClientError::Sse(_))) {
|
||||
saw_sse_err = true;
|
||||
}
|
||||
}
|
||||
assert!(saw_sse_err, "expected Sse error from stream consumer");
|
||||
|
||||
let received = server.received_requests().await.unwrap();
|
||||
assert_eq!(received.len(), 1, "mid-stream Sse must not reopen stream");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_retryable_status_returns_api_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let transport = build_transport(server.uri(), false);
|
||||
match transport.stream(Request::default()).await {
|
||||
Err(ClientError::Api {
|
||||
status: Some(401), ..
|
||||
}) => {}
|
||||
Err(other) => panic!("expected Api(401), got {other:?}"),
|
||||
Ok(_) => panic!("expected error"),
|
||||
}
|
||||
|
||||
let received = server.received_requests().await.unwrap();
|
||||
assert_eq!(received.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use agen::Engine;
|
||||
use agen::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
use agen::llm_client::scheme::anthropic::AnthropicScheme;
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let cap = ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
};
|
||||
let client = HttpTransport::new(
|
||||
AnthropicScheme::new(),
|
||||
"dummy-model".to_string(),
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
cap,
|
||||
);
|
||||
let engine = Engine::new(client);
|
||||
let mut locked = engine.lock();
|
||||
let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
|
||||
let _ = locked.register_tool(def);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope
|
||||
--> tests/ui/locked_register_tool.rs:27:20
|
||||
|
|
||||
27 | let _ = locked.register_tool(def);
|
||||
| ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>`
|
||||
|
|
||||
= note: the method was found for
|
||||
- `Engine<C>`
|
||||
@@ -0,0 +1,28 @@
|
||||
use agen::Engine;
|
||||
use agen::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
use agen::llm_client::scheme::anthropic::AnthropicScheme;
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let cap = ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
};
|
||||
let client = HttpTransport::new(
|
||||
AnthropicScheme::new(),
|
||||
"dummy-model".to_string(),
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
cap,
|
||||
);
|
||||
let engine = Engine::new(client);
|
||||
let handle = engine.tool_server_handle();
|
||||
let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
|
||||
let _ = handle.register_tool(def);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
error[E0624]: method `register_tool` is private
|
||||
--> tests/ui/tool_server_handle_register_tool.rs:27:20
|
||||
|
|
||||
27 | let _ = handle.register_tool(def);
|
||||
| ^^^^^^^^^^^^^ private method
|
||||
|
|
||||
::: src/tool_server.rs
|
||||
|
|
||||
| pub(crate) fn register_tool(&self, factory: EngineToolDefinition) {
|
||||
| ----------------------------------------------------------------- private method defined here
|
||||
Reference in New Issue
Block a user