docs: consolidate design documentation and standardize crate English

This commit is contained in:
2026-10-12 14:58:00 +09:00
parent a633a53274
commit 4efb0ace1f
404 changed files with 4556 additions and 11654 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ 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"]
include = ["src/**", "tests/**", "examples/*.rs", "README.md", "LICENSE"]
autoexamples = false
[features]
+10 -87
View File
@@ -1,91 +1,14 @@
# 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` integrates streaming LLM responses and tool execution in an agent engine.
It manages turns, history appends, pause/resume, and retries, while the LLM client
layer handles wire formats and authentication. The caller owns history storage,
worker lifecycle control, and compaction.
> `agen` is pre-1.0. Public APIs may change between minor releases.
## Design entry points
## 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), keep conversation history in your application, 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, History};
use agen::llm_client::LlmClient;
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
let mut history = History::new();
let output = Engine::new(client)
.system_prompt("You are a concise assistant.")
.run(&mut history, "Explain typed state in one sentence.")
.await;
let mut engine = output.engine;
let _result = engine.run(&mut history, "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).
- [`Engine`]: turn execution, interruption, and resumption
- [`History`]: model input paired with caller-owned annotations
- [`state`] and [`prune`]: prefix protection and request-only history projections
- [`llm_client`] and [`timeline`]: wire-format conversion and event processing
- [`interceptor`]: host-owned control decisions
-62
View File
@@ -1,62 +0,0 @@
# 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.
-39
View File
@@ -1,39 +0,0 @@
# 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.
+1 -1
View File
@@ -368,7 +368,7 @@ fn create_client(args: &Args) -> Result<Box<dyn LlmClient>, String> {
ResolvedAuth::ApiKey(api_key),
)),
Provider::Ollama => {
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
// Ollama uses the Anthropic scheme with a custom base_url and no authentication.
let scheme = AnthropicScheme::new();
let cap = default_capability();
Ok(Box::new(HttpTransport::new(
@@ -108,7 +108,7 @@ async fn run_scenario_with_ollama(
subdir: &str,
model: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
// Ollama uses the Anthropic scheme with a custom base_url and no authentication.
let model = model.as_deref().unwrap_or("llama3");
let client = HttpTransport::new(
AnthropicScheme::new(),
+42 -24
View File
@@ -1,3 +1,20 @@
//! Provider-independent orchestration of turns and tool execution.
//!
//! The Engine uses common Request and Event types to keep wire formats and
//! authentication out of the turn loop. It delegates caller control decisions to
//! Interceptor and does not own notification intake, storage, or segment switches
//! during compaction.
//!
//! New history items enter in-memory history only after their annotation callback
//! succeeds. Hosts that require persistence record items in that callback.
//! Request-time reordering and pruning operate on a clone, preserving stored order
//! and caller-owned annotations.
//!
//! Retries before a stream opens differ from continuation requests after partial
//! output has been recorded. A stream failure after early tool dispatch disables
//! automatic continuation: regenerating the same side effect under a new call ID
//! is unsafe, so the Engine finalizes the executions it owns and returns an error.
use std::collections::{HashMap, HashSet, VecDeque};
use std::{future::Future, marker::PhantomData, pin::Pin, sync::Arc, time::Instant};
@@ -464,7 +481,7 @@ const MAX_STREAM_CONTINUATIONS: u32 = 3;
///
/// Calling `run()` on a `Mutable` Engine consumes it and returns a
/// `Locked` Engine together with the result. The engine borrows the caller's
/// [`History`](crate::History) only while running, so host annotations stay with
/// [`History`] only while running, so host annotations stay with
/// the host-owned history and are never projected to providers.
///
/// ```ignore
@@ -488,7 +505,7 @@ const MAX_STREAM_CONTINUATIONS: u32 = 3;
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmRetryNotice {
/// 直近で失敗した attempt 番号。1 origin。
/// Number of the most recently failed attempt, starting at 1.
pub failed_attempt: u32,
pub max_attempts: u32,
pub wait: std::time::Duration,
@@ -539,6 +556,14 @@ impl Drop for ResponseStreamPump {
}
}
/// Coordinates LLM responses, tool results, and control callbacks in one turn loop.
///
/// History belongs to the caller. `run` appends new input and starts a logical run;
/// `resume` continues a paused or yielded run without creating a new user message.
/// The turn budget survives pauses and yields, so resuming does not reset the limit.
///
/// Type states restrict Engine configuration changes; they do not guarantee that
/// external History remains unchanged or that the provider reports a cache hit.
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
/// LLM client
client: C,
@@ -550,8 +575,8 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
text_block_collector: TextBlockCollector,
/// Tool call collector (Timeline handler)
tool_call_collector: ToolCallCollector,
/// Thinking block collector (Timeline handler)。metadata 付きで完了した
/// Thinking block を 1 ターン分バッファし、history に append する。
/// Thinking block collector (Timeline handler). Buffers completed blocks and
/// their metadata for one turn before appending them to history.
thinking_block_collector: ThinkingBlockCollector,
/// Tool server handle
tool_server: ToolServerHandle,
@@ -561,12 +586,9 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
system_prompt: Option<String>,
/// History length at lock time (only meaningful in Locked state)
locked_prefix_len: usize,
/// AgentTurn count across the lifetime of this Engine.
/// Cumulative AgentTurns started during this Engine's lifetime.
///
/// Once retry (`agen-stream-continuation`) is implemented, an
/// AgentTurn collapses N retried `LlmCall`s with identical input;
/// today retry is not implemented so AgentTurn and LlmCall fire 1:1
/// and the increment site (the LLM-call loop) is shared.
/// The logical run's turn budget is tracked separately in `active_run_turn_count`.
turn_count: usize,
/// AgentTurns consumed by the currently active logical run.
///
@@ -1299,10 +1321,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
/// Build assistant response items from reasoning items, text blocks, and tool calls.
///
/// Reasoning items come first (Anthropic / OpenAI Responses 双方ともに
/// アシスタント応答内で reasoning は先頭に並ぶ仕様)。これは Anthropic
/// が新世代モデルで thinking ブロックを assistant メッセージの先頭に
/// 置くことを要求するためでもある。
/// Reasoning items precede other assistant output for both Anthropic and
/// OpenAI Responses. This also satisfies newer Anthropic models' requirement
/// that thinking blocks appear at the start of the assistant message.
fn build_assistant_items(
&self,
reasoning_items: &[crate::llm_client::event::ReasoningBlockData],
@@ -3293,7 +3314,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
let event = match result {
Ok(event) => event,
Err(err) => {
// 部分情報でも発火しておく(料金会計用)
// Emit even partial usage for billing accounting.
self.timeline.flush_usage();
return Ok(StreamResponseOutput {
completion: StreamCompletion::Interrupted {
@@ -3585,7 +3606,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
}
}
}
// ストリーム完了時に集約済み Usage を 1 度だけ発火
// Emit aggregated usage once when the stream completes.
self.timeline.flush_usage();
debug!(event_count = event_count, "Stream completed");
Ok(StreamResponseOutput {
@@ -3900,19 +3921,16 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
}
}
/// Lock and transition to Locked state
/// Finalizes configuration and transitions to Locked.
///
/// Flushes pending tool factories, then fixes the current system prompt
/// and history as a "committed prefix". After this, only `run()` / `resume()`
/// may append to history, ensuring cache hits.
///
/// Most callers should use [`run()`](Self::run) instead, which calls
/// this internally. Use `lock()` directly only when you need the
/// `Locked` engine back on error (e.g. in a persistence layer).
/// Flushes pending tool factories and records the current history length as the
/// prefix boundary. External History mutations remain possible, so the caller
/// is also responsible for preserving the prefix. This transition changes the
/// type state; it does not persist history.
///
/// # Panics
///
/// Panics if a pending tool factory produces a duplicate name.
/// Panics if pending tool factories produce duplicate names.
pub fn lock(self, history: &History<A>) -> Engine<C, Locked, A> {
self.tool_server.flush_pending();
let locked_prefix_len = history.len();
+11 -6
View File
@@ -1,9 +1,8 @@
//! Typed conversation history containers.
//! History that pairs model input with host-owned annotations.
//!
//! Agen keeps provider-visible [`Item`](crate::Item) values separate from any
//! host-domain provenance. The host chooses the annotation type `A`, while Agen
//! preserves each item and annotation as one entry for clone/truncate/restore
//! style history operations.
//! Only [`Item`] is projected to providers; annotation `A` holds storage identifiers
//! and input provenance. Cloning, truncation, and restoration keep each pair together.
//! The caller's append callback owns persistence; this container does not.
use serde::{Deserialize, Serialize};
@@ -40,7 +39,13 @@ impl HistoryEntry<()> {
}
}
/// Conversation history with one annotation per item.
/// Ordered history of model input and host-owned annotations.
///
/// [`History::append_with`] runs the callback before adding an item. A host that
/// persists in that callback keeps failed writes out of subsequent model requests.
/// Batch appends commit one item at a time; later failures do not roll back earlier
/// items. Direct append and replacement APIs do not persist, so the host must
/// distinguish restoration from normal appends.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct History<A = ()> {
entries: Vec<HistoryEntry<A>>,
+5 -4
View File
@@ -1,8 +1,9 @@
//! Interceptor - control flow delegation for the Engine execution loop
//! Host-owned control decisions at Engine execution boundaries.
//!
//! 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.
//! [`Interceptor`] delegates approval, skip, pause, and abort decisions to the host,
//! keeping host-specific policy out of the Engine. Additional model input must go
//! through history appends: inserting new facts only into an in-flight request
//! would make its input impossible to reconstruct from stored history.
use std::sync::Arc;
+2 -3
View File
@@ -42,10 +42,9 @@ pub use tool::{
};
pub use usage_record::UsageRecord;
/// Implementation dependencies used by code generated from `agen` macros.
/// Implementation dependencies referenced by macro-generated code.
///
/// This module is not a stable user-facing API. It is public only because macro expansion
/// happens in the downstream crate.
/// Public for access from downstream macro expansions, not a stable user-facing API.
#[doc(hidden)]
pub mod __private {
pub use async_trait;
+20 -20
View File
@@ -1,43 +1,43 @@
//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ。
//! Authentication requirements for `Scheme` implementations and the transport layer,
//! plus dynamic authentication providers.
//!
//! `AuthRequirement` は scheme が宣言する「この scheme はどんな認証を
//! 期待するか」のランタイム記述で、設定ファイルや環境変数などから
//! [`super::transport::ResolvedAuth`] を組み立てる責務は呼び出し側にある。
//! `AuthRequirement` describes the authentication a scheme expects at runtime.
//! The caller is responsible for building [`super::transport::ResolvedAuth`]
//! from configuration files, environment variables, or other sources.
//!
//! リクエスト毎にトークンが変わり得る認証は [`AuthProvider`] trait を
//! 実装し、[`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。
//! Authentication whose tokens may change per request implements [`AuthProvider`]
//! and is passed to the transport via [`super::transport::ResolvedAuth::Custom`].
use async_trait::async_trait;
use reqwest::header::{HeaderName, HeaderValue};
use super::error::ClientError;
/// `Scheme::required_auth()` が返す認証要件。
/// Authentication requirements returned by `Scheme::required_auth()`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthRequirement {
/// 認証を行わない(Ollama など)
/// No authentication (e.g. Ollama).
None,
/// `Authorization: Bearer <token>` ヘッダ(token は API key 相当)
/// `Authorization: Bearer <token>` header (the token acts as an API key).
Bearer,
/// `x-api-key: <token>` ヘッダ(Anthropic 形式)
/// `x-api-key: <token>` header (Anthropic format).
XApiKey,
/// クエリパラメータ `?<name>=<token>`(Gemini 形式)
/// Query parameter `?<name>=<token>` (Gemini format).
QueryParam { name: &'static str },
/// 複合ヘッダ(呼び出し側が [`AuthProvider`] で解決)
/// Combined headers (resolved by the caller through [`AuthProvider`]).
Custom,
}
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
/// Provider that builds authentication headers dynamically for each request.
///
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
/// 必要があるケースで使う。実体は呼び出し側に置き、agen は
/// trait を知るだけ。
/// Use this when access tokens are refreshed or multiple headers must be injected
/// together. The implementation belongs to the caller; agen only knows the trait.
///
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら
/// 実装側でセットすること。
/// Returned headers are inserted directly into `HeaderMap`. The scheme's default
/// authentication headers, including `Authorization`, are not sent; the
/// implementation must set them if needed.
#[async_trait]
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
/// Return authentication headers for one request, refreshing internally if needed.
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
}
+26 -26
View File
@@ -1,16 +1,16 @@
//! モデル能力メタデータ
//! Model capability metadata.
//!
//! `ModelCapability` はモデルが持つ機能差を表現する。scheme は同じでも
//! モデルごとに reasoning 可否や prompt caching 方式が違うため、scheme
//! から分離して保持する。
//! `ModelCapability` represents differences in model features. Reasoning support
//! and prompt caching strategies may vary even within one scheme, so this
//! metadata is kept separate from the scheme.
//!
//! 値の供給経路は 2 通り:
//! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル)
//! 2. `ModelConfig::capability` での明示 override(未知モデル、または上書き)
//! The client construction layer supplies model-specific capabilities and overrides.
//! Schemes provide only wire-level defaults through `Scheme::default_capability`;
//! this module does not own a model registry or application configuration format.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// モデル能力メタデータ
/// Model capability metadata.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelCapability {
pub tool_calling: ToolCallingSupport,
@@ -23,7 +23,7 @@ pub struct ModelCapability {
}
impl ModelCapability {
/// 何もサポートしない安全側デフォルト。未知モデルのフォールバック用。
/// Conservative default with no supported features, used for unknown models.
pub const fn minimal() -> Self {
Self {
tool_calling: ToolCallingSupport::None,
@@ -35,55 +35,55 @@ impl ModelCapability {
}
}
/// ツール呼び出しサポート
/// Tool calling support.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallingSupport {
/// 非サポート
/// Unsupported.
None,
/// 1 回のレスポンスで 1 ツールのみ
/// Only one tool per response.
Sequential,
/// 1 回のレスポンスで複数ツール並行
/// Multiple parallel tools per response.
Parallel,
}
/// Structured output サポート
/// Structured output support.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StructuredOutput {
None,
/// `json_object` モード(スキーマなし JSON 強制)
/// `json_object` mode (enforces JSON without a schema).
JsonObject,
/// JSON Schema 指定で構造化出力
/// Structured output with a JSON Schema.
JsonSchema,
}
/// Reasoning(extended thinking)サポート
/// Reasoning (extended thinking) support.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningSupport {
/// OpenAI 形式: `reasoning.effort` (low/medium/high)
/// OpenAI format: `reasoning.effort` (low/medium/high).
Effort,
/// Anthropic 形式: `thinking.budget_tokens`
/// Anthropic format: `thinking.budget_tokens`.
BudgetTokens,
/// 両対応(内部では共通 `ReasoningControl` として扱い、各 scheme で投影)
/// Both (handled internally as shared `ReasoningControl`, projected by each scheme).
Both,
}
/// Prompt caching 戦略
/// Prompt caching strategy.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CacheStrategy {
/// Anthropic: `cache_control` マーカーを明示挿入
/// Anthropic: insert explicit `cache_control` markers.
Explicit { max_breakpoints: u8 },
/// それ以外: サーバ側自動 prefix、または未サポート
/// Other providers: automatic server-side prefix caching, or unsupported.
Auto,
}
/// Reasoning 制御(共通型、scheme 側で各社形式に投影)。
/// Shared reasoning control, projected into each provider's format by the scheme.
///
/// 文字列は provider-native な effort label、数値は provider-native な
/// thinking budget token として扱う。どちらか一方だけを型で表現する。
/// Strings are provider-native effort labels; numbers are provider-native
/// thinking token budgets. The type represents only one at a time.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum ReasoningControl {
+18 -18
View File
@@ -1,4 +1,4 @@
//! LLMクライアント共通trait定義
//! Shared LLM client trait definitions.
use std::pin::Pin;
@@ -6,19 +6,19 @@ use crate::llm_client::{ClientError, Request, RequestConfig, event::Event};
use async_trait::async_trait;
use futures::Stream;
/// 設定に関する警告
/// Configuration warning.
///
/// プロバイダがサポートしていない設定を使用した場合に返される。
/// Returned when using a configuration option unsupported by the provider.
#[derive(Debug, Clone)]
pub struct ConfigWarning {
/// 設定オプション名
/// Configuration option name.
pub option_name: &'static str,
/// 警告メッセージ
/// Warning message.
pub message: String,
}
impl ConfigWarning {
/// 新しい警告を作成
/// Create a new warning.
pub fn unsupported(option_name: &'static str, provider_name: &str) -> Self {
Self {
option_name,
@@ -48,19 +48,19 @@ pub enum ToolCallCompletionSupport {
ResponseComplete,
}
/// LLMクライアントのtrait
/// LLM client trait.
///
/// 各プロバイダはこのtraitを実装し、統一されたインターフェースを提供する。
/// Each provider implements this trait to expose a unified interface.
#[async_trait]
pub trait LlmClient: Send + Sync {
/// ストリーミングリクエストを送信し、Eventストリームを返す
/// Send a streaming request and return an Event stream.
///
/// # Arguments
/// * `request` - リクエスト情報
/// * `request` - Request information.
///
/// # Returns
/// * `Ok(Stream)` - イベントストリーム
/// * `Err(ClientError)` - エラー
/// * `Ok(Stream)` - Event stream.
/// * `Err(ClientError)` - Error.
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError>;
/// Clone this client into a new `Box<dyn LlmClient>`.
@@ -74,15 +74,15 @@ pub trait LlmClient: Send + Sync {
ToolCallCompletionSupport::ResponseComplete
}
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
/// Validate configuration and return warnings for unsupported options.
///
/// # Arguments
/// * `config` - バリデーション対象の設定
/// * `config` - Configuration to validate.
///
/// # Returns
/// サポートされていない設定に対する警告のリスト
/// List of warnings for unsupported options.
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
// デフォルト実装: 全ての設定をサポート
// Default implementation: all options are supported.
let _ = config;
Vec::new()
}
@@ -94,9 +94,9 @@ impl Clone for Box<dyn LlmClient> {
}
}
/// `Box<dyn LlmClient>` に対する `LlmClient` の実装
/// `LlmClient` implementation for `Box<dyn LlmClient>`.
///
/// これにより、動的ディスパッチを使用するクライアントも `Engine` で利用可能になる。
/// Allows clients using dynamic dispatch to be used with `Engine`.
#[async_trait]
impl LlmClient for Box<dyn LlmClient> {
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
+20 -15
View File
@@ -1,17 +1,22 @@
//! LLMクライアントエラー型
//! Report provider communication failures to turn control.
//!
//! Context limit rejections are classified from structured provider codes;
//! fuzzy body matches must not imply success or retryability. The Engine maps
//! client errors to typed execution results, which the host records as the Run's
//! terminal outcome. Live diagnostic display and persistence are separate concerns.
use std::{fmt, time::Duration};
/// LLMクライアントのエラー
/// LLM client error.
#[derive(Debug)]
pub enum ClientError {
/// HTTPリクエストエラー
/// HTTP request error.
Http(reqwest::Error),
/// JSONパースエラー
/// JSON parse error.
Json(serde_json::Error),
/// SSEパースエラー
/// SSE parse error.
Sse(String),
/// APIエラー (プロバイダからのエラーレスポンス)
/// API error (an error response from the provider).
Api {
status: Option<u16>,
code: Option<String>,
@@ -26,7 +31,7 @@ pub enum ClientError {
phase: &'static str,
timeout: Duration,
},
/// 設定エラー
/// Configuration error.
Config(String),
}
@@ -137,16 +142,16 @@ pub(crate) fn is_image_size_diagnostic(code: Option<&str>, message: &str) -> boo
|| (message.contains("image dimensions") && message.contains("exceed")))
}
/// transient な失敗としてリトライ対象になるかを判定する。
/// Determine whether a failure is transient and eligible for retry.
///
/// 対象:
/// - `Api { status }` のうち 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
/// - `Http(reqwest::Error)` のうち `is_connect()` または `is_timeout()`
/// - `Timeout { .. }` の lifecycle hard timeout
/// Eligible failures:
/// - `Api { status }` with 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529.
/// - `Http(reqwest::Error)` with `is_connect()` or `is_timeout()`.
/// - Lifecycle hard timeouts from `Timeout { .. }`.
///
/// それ以外(Json、Sse、Config、上記以外の Api ステータス)は false。
/// SSE 読み出し開始後の失敗は呼び出し側で `Sse` として上に流すため、
/// ここで対象外にしておけば自動的に弾かれる。
/// Other failures (Json, Sse, Config, and other Api statuses) return false.
/// Callers propagate failures after SSE reading begins as `Sse`, so excluding
/// them here automatically prevents retries.
pub fn is_retryable(error: &ClientError) -> bool {
match error {
ClientError::Api {
+98 -99
View File
@@ -1,6 +1,6 @@
//! LLMクライアント層のイベント型
//! Event types for the LLM client layer.
//!
//! 各LLMプロバイダからのストリーミングレスポンスを表現するイベント型。
//! Events representing streaming responses from LLM providers.
use serde::{Deserialize, Serialize};
@@ -8,43 +8,42 @@ use serde::{Deserialize, Serialize};
// Core Event Types (from llm_client layer)
// =============================================================================
/// LLMからのストリーミングイベント
/// Streaming event from an LLM.
///
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
/// 統一的に処理されます。
/// Responses from all LLM providers are handled uniformly as streams of `Event`.
///
/// # イベントの種類
/// # Event types
///
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
/// - **Meta events**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
/// - **Block events**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
///
/// # ブロックのライフサイクル
/// # Block lifecycle
///
/// テキスト、thinking、ツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
/// の順序でイベントが発生します。thinking の round-trip metadata は
/// `BlockStop.reasoning` に載ります。
/// Text, thinking, and tool calls emit `BlockStart` → `BlockDelta` (multiple) →
/// `BlockStop` in order. Thinking round-trip metadata is carried in
/// `BlockStop.reasoning`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Event {
/// ハートビート
/// Heartbeat.
Ping(PingEvent),
/// トークン使用量
/// Token usage.
Usage(UsageEvent),
/// ストリームのステータス変化
/// Stream status change.
Status(StatusEvent),
/// エラー発生
/// Error occurred.
Error(ErrorEvent),
/// Scheme が生成内容として解釈しない未対応 SSE イベント。
/// Unsupported SSE event that the Scheme does not interpret as generated content.
///
/// stream trace 用の観測イベントであり、timeline / history には反映しない。
/// An observation event for stream tracing, not reflected in the timeline or history.
UnhandledSse(UnhandledSseEvent),
/// ブロック開始(テキスト、ツール使用等)
/// Block start (text, tool use, etc.).
BlockStart(BlockStart),
/// ブロックの差分データ
/// Block delta data.
BlockDelta(BlockDelta),
/// ブロック正常終了
/// Normal block completion.
BlockStop(BlockStop),
/// ブロック中断
/// Block abort.
BlockAbort(BlockAbort),
}
@@ -52,57 +51,57 @@ pub enum Event {
// Meta Events
// =============================================================================
/// Pingイベント(ハートビート)
/// Ping event (heartbeat).
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PingEvent {
pub timestamp: Option<u64>,
}
/// 使用量イベント
/// Usage event.
///
/// プロバイダから受信した 1 LLM リクエスト分のトークン会計。
/// 各 scheme で正規化され、フィールドの意味は全プロバイダ共通:
/// Token accounting received from the provider for one LLM request.
/// Each scheme normalizes it; field meanings are shared across providers:
///
/// - `input_tokens` は **送信した prompt prefix 全体の占有量**(プロンプト全長)。
/// キャッシュヒット分も含まれる。Anthropic は raw API では非キャッシュ分のみを
/// `input_tokens` として返すため、`AnthropicScheme::convert_usage` で
/// `cache_read + cache_creation` を加算してこの規約に揃えている。
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` は上記の内訳で、
/// 料金会計用。占有量からは差し引かない。
/// - `input_tokens` is the **occupancy of the entire submitted prompt prefix**
/// (full prompt length), including cache hits. Anthropic's raw API returns only
/// uncached tokens as `input_tokens`, so `AnthropicScheme::convert_usage` adds
/// `cache_read + cache_creation` to follow this convention.
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` are breakdowns
/// for billing. They are not subtracted from occupancy.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct UsageEvent {
/// 送信した prompt prefix の総トークン数(占有量、キャッシュ込み)
/// Total tokens in the submitted prompt prefix (occupancy, including cached tokens).
pub input_tokens: Option<u64>,
/// このリクエストで生成された出力トークン数
/// Output tokens generated by this request.
pub output_tokens: Option<u64>,
/// `input_tokens + output_tokens`
pub total_tokens: Option<u64>,
/// `input_tokens` のうちキャッシュから読まれた分(割引料金)
/// Portion of `input_tokens` read from cache (discounted rate).
pub cache_read_input_tokens: Option<u64>,
/// `input_tokens` のうちこのリクエストでキャッシュに書かれた分(割増料金、Anthropic)
/// Portion of `input_tokens` written to cache by this request (premium rate, Anthropic).
pub cache_creation_input_tokens: Option<u64>,
}
/// ステータスイベント
/// Status event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusEvent {
pub status: ResponseStatus,
}
/// レスポンスステータス
/// Response status.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ResponseStatus {
/// ストリーム開始
/// Stream started.
Started,
/// 正常完了
/// Completed normally.
Completed,
/// キャンセルされた
/// Cancelled.
Cancelled,
/// エラー発生
/// Error occurred.
Failed,
}
/// エラーイベント
/// Error event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorEvent {
pub code: Option<String>,
@@ -118,10 +117,10 @@ impl ErrorEvent {
}
}
/// 未対応 SSE イベントの観測用メタイベント。
/// Observation-only meta event for unsupported SSE events.
///
/// `data_preview` は provider から受け取った raw SSE data の bounded preview、
/// `data_len` は preview 前の raw data byte length。
/// `data_preview` is a bounded preview of raw SSE data received from the provider;
/// `data_len` is the raw data's byte length before preview truncation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnhandledSseEvent {
pub provider: String,
@@ -134,27 +133,27 @@ pub struct UnhandledSseEvent {
// Block Types
// =============================================================================
/// ブロックの種別
/// Block type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockType {
/// テキスト生成
/// Text generation.
Text,
/// 思考 (Claude Extended Thinking等)
/// Thinking (e.g. Claude Extended Thinking).
Thinking,
/// ツール呼び出し
/// Tool call.
ToolUse,
/// ツール結果
/// Tool result.
ToolResult,
}
/// ブロック開始イベント
/// Block start event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStart {
/// ブロックのインデックス
/// Block index.
pub index: usize,
/// ブロックの種別
/// Block type.
pub block_type: BlockType,
/// ブロック固有のメタデータ
/// Block-specific metadata.
pub metadata: BlockMetadata,
}
@@ -164,7 +163,7 @@ impl BlockStart {
}
}
/// ブロックのメタデータ
/// Block metadata.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockMetadata {
Text,
@@ -173,28 +172,28 @@ pub enum BlockMetadata {
ToolResult { tool_use_id: String },
}
/// ブロックデルタイベント
/// Block delta event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockDelta {
/// ブロックのインデックス
/// Block index.
pub index: usize,
/// デルタの内容
/// Delta content.
pub delta: DeltaContent,
}
/// デルタの内容
/// Delta content.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeltaContent {
/// テキストデルタ
/// Text delta.
Text(String),
/// 思考デルタ
/// Thinking delta.
Thinking(String),
/// ツール引数のJSON部分文字列
/// Partial JSON string for tool arguments.
InputJson(String),
}
impl DeltaContent {
/// デルタのブロック種別を取得
/// Get the delta's block type.
pub fn block_type(&self) -> BlockType {
match self {
DeltaContent::Text(_) => BlockType::Text,
@@ -204,20 +203,20 @@ impl DeltaContent {
}
}
/// ブロック停止イベント
/// Block stop event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStop {
/// ブロックのインデックス
/// Block index.
pub index: usize,
/// ブロックの種別
/// Block type.
pub block_type: BlockType,
/// 停止理由
/// Stop reason.
pub stop_reason: Option<StopReason>,
/// Thinking block の停止時に確定した reasoning round-trip metadata。
/// Reasoning round-trip metadata finalized when a Thinking block stops.
///
/// `None` の Thinking block は live streaming / trace 用で、history に
/// `Item::Reasoning` として永続化しない。`Some` の場合は block lifecycle
/// が永続化の authoritative source になる。
/// Thinking blocks with `None` are for live streaming / tracing and are not
/// persisted in history as `Item::Reasoning`. With `Some`, the block lifecycle
/// is the authoritative source for persistence.
pub reasoning: Option<ReasoningBlockData>,
}
@@ -227,14 +226,14 @@ impl BlockStop {
}
}
/// ブロック中断イベント
/// Block abort event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockAbort {
/// ブロックのインデックス
/// Block index.
pub index: usize,
/// ブロックの種別
/// Block type.
pub block_type: BlockType,
/// 中断理由
/// Abort reason.
pub reason: String,
}
@@ -244,36 +243,36 @@ impl BlockAbort {
}
}
/// Thinking block stop で確定した reasoning material。
/// Reasoning material finalized at Thinking block stop.
///
/// `Item::Reasoning` の round-trip に必要な provider material を保持する。
/// `text` は deltas から収集した本文を上書きするために使う(metadata-only
/// reasoning block や provider completion event で全文が届くケース)。
/// Holds provider material needed to round-trip `Item::Reasoning`.
/// `text` overrides the body collected from deltas (for metadata-only reasoning
/// blocks or provider completion events that deliver the full text).
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ReasoningBlockData {
/// scheme 側で観測した item id(OpenAI Responses の `id`)。
/// Item ID observed by the scheme (OpenAI Responses `id`).
pub id: Option<String>,
/// reasoning 本体テキスト。`None` の場合は block delta 収集結果を使う。
/// Reasoning body text. `None` uses the collected block deltas.
pub text: Option<String>,
/// summary (OpenAI Responses の `summary_text[]`)。他 scheme は空。
/// Summary (OpenAI Responses `summary_text[]`); empty for other schemes.
pub summary: Vec<String>,
/// 暗号化された opaque blob(Anthropic `redacted_thinking.data` /
/// OpenAI Responses `encrypted_content`)。
/// Encrypted opaque blob (Anthropic `redacted_thinking.data` /
/// OpenAI Responses `encrypted_content`).
pub encrypted_content: Option<String>,
/// Anthropic extended thinking signature。round-trip 必須。
/// Anthropic extended thinking signature, required for round-tripping.
pub signature: Option<String>,
}
/// 停止理由
/// Stop reason.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason {
/// 自然終了
/// Natural end of turn.
EndTurn,
/// 最大トークン数到達
/// Maximum token count reached.
MaxTokens,
/// ストップシーケンス到達
/// Stop sequence reached.
StopSequence,
/// ツール使用
/// Tool use.
ToolUse,
}
@@ -282,7 +281,7 @@ pub enum StopReason {
// =============================================================================
impl Event {
/// テキストブロック開始イベントを作成
/// Create a text block start event.
pub fn text_block_start(index: usize) -> Self {
Event::BlockStart(BlockStart {
index,
@@ -291,7 +290,7 @@ impl Event {
})
}
/// テキストデルタイベントを作成
/// Create a text delta event.
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
@@ -299,7 +298,7 @@ impl Event {
})
}
/// テキストブロック停止イベントを作成
/// Create a text block stop event.
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
Event::BlockStop(BlockStop {
index,
@@ -309,7 +308,7 @@ impl Event {
})
}
/// ツール使用ブロック開始イベントを作成
/// Create a tool use block start event.
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
Event::BlockStart(BlockStart {
index,
@@ -321,7 +320,7 @@ impl Event {
})
}
/// ツール引数デルタイベントを作成
/// Create a tool argument delta event.
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
@@ -329,7 +328,7 @@ impl Event {
})
}
/// ツール使用ブロック停止イベントを作成
/// Create a tool use block stop event.
pub fn tool_use_stop(index: usize) -> Self {
Event::BlockStop(BlockStop {
index,
@@ -339,7 +338,7 @@ impl Event {
})
}
/// 使用量イベントを作成
/// Create a usage event.
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
Event::Usage(UsageEvent {
input_tokens: Some(input_tokens),
@@ -350,7 +349,7 @@ impl Event {
})
}
/// Pingイベントを作成
/// Create a Ping event.
pub fn ping() -> Self {
Event::Ping(PingEvent { timestamp: None })
}
+8 -15
View File
@@ -1,20 +1,13 @@
//! LLMクライアント層
//! Boundary between the Engine and provider communication.
//!
//! 各LLMプロバイダと通信し、統一された[`Event`]
//! ストリームを出力します。
//! [`LlmClient`] accepts a shared [`Request`] and returns normalized [`Event`]s.
//! Provider-specific bodies, authentication requirements, and stream parsing are
//! separated into [`scheme`] and [`transport`] to limit how API changes affect
//! turn progression and tool execution semantics.
//!
//! # サポートするプロバイダ
//!
//! - Anthropic (Claude)
//! - OpenAI (GPT-4, etc.)
//! - Google (Gemini)
//! - Ollama (ローカルLLM)
//!
//! # アーキテクチャ
//!
//! - [`LlmClient`] - プロバイダ共通のtrait
//! - `providers`: プロバイダ固有のクライアント実装
//! - `scheme`: APIスキーマ(リクエスト/レスポンス変換)
//! Scheme capabilities describe what the wire format can safely handle.
//! The client construction layer owns per-model capability tables and alias
//! resolution; the Engine does not depend on that selection process.
pub mod auth;
pub mod capability;
+17 -17
View File
@@ -1,25 +1,25 @@
//! LLM response stream を開く前の transient error 向けリトライポリシー。
//! Retry policy for transient errors before opening an LLM response stream.
//!
//! `LlmClient::stream` の open error に対して `is_retryable` を見て
//! retry / backoff / cancellation をまとめて管理する。
//! SSE 読み出し開始後の失敗は対象外。
//! Uses `is_retryable` on `LlmClient::stream` open errors to manage retries,
//! backoff, and cancellation together. Failures after SSE reading begins
//! are excluded.
use std::time::Duration;
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
/// Policy combining exponential backoff, jitter, and a cumulative timeout.
///
/// `Default` は agen 全体の固定値を返す。呼び出し側からの上書きが
/// 必要になったら拡張する。
/// `Default` returns fixed values shared across agen. Extend this if caller
/// overrides become necessary.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から
/// フルジッターで実際の wait を抽選する。
/// Exponential base. Full jitter samples the actual wait from an upper bound
/// of `base * 2^attempt`, capped at `cap`.
pub base: Duration,
/// 1 回あたりの wait の上限。
/// Maximum wait per attempt.
pub cap: Duration,
/// 試行の合計回数(初回 + リトライ)。`1` ならリトライしない。
/// Total attempts (initial attempt + retries). `1` disables retries.
pub max_attempts: u32,
/// 初回送信開始からの累積タイムアウト。これを超える wait は打ち切る。
/// Cumulative timeout from the first send. Waits exceeding it are cut short.
pub total_timeout: Duration,
}
@@ -35,8 +35,8 @@ impl Default for RetryPolicy {
}
impl RetryPolicy {
/// `attempt` 回目の失敗(0-indexed)後に待つ時間を返す。
/// `Retry-After` で上書きしたい場合は呼び出さず、その値をそのまま使う。
/// Return the wait after failure number `attempt` (zero-indexed).
/// For a `Retry-After` override, use its value directly instead of calling this.
pub fn backoff(&self, attempt: u32) -> Duration {
let shift = attempt.min(20);
let base_nanos = self.base.as_nanos() as u64;
@@ -47,9 +47,9 @@ impl RetryPolicy {
}
}
/// `[0, max_nanos]` から擬似乱数的に 1 つ取り出す。`SystemTime` の
/// 下位ビットを splitmix64 で攪拌するだけの軽量実装で、暗号的乱数性は
/// 持たないがフルジッターのぶつかり回避には十分。
/// Sample a pseudorandom value from `[0, max_nanos]`. This lightweight
/// implementation mixes the low bits of `SystemTime` with splitmix64.
/// It is not cryptographically random, but suffices to avoid full-jitter collisions.
fn jitter_nanos(max_nanos: u64) -> u64 {
if max_nanos == 0 {
return 0;
@@ -1,17 +1,17 @@
//! Anthropic scheme の wire-level 既定 capability。
//! Wire-level default capability for the Anthropic scheme.
//!
//! モデル ID 固有のテーブル(`claude-*` など)は client construction layer
//! の責務。ここでは未知モデルでも「この wire で
//! 安全に送れる最小共通項」を返すだけに留める。
//! Model ID tables (e.g. `claude-*`) belong to the client construction layer.
//! This module returns only the minimum common features safe to send over
//! this wire, even for unknown models.
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
};
/// Scheme 既定の capability。
/// Scheme default capability.
///
/// Ollama の `/v1/messages` 流用を想定して `cache_control` を送らない
/// `CacheStrategy::Auto` にする。
/// Uses `CacheStrategy::Auto` to avoid sending `cache_control`, allowing reuse
/// with Ollama's `/v1/messages` endpoint.
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
@@ -1,6 +1,6 @@
//! Anthropic SSEイベントパース
//! Anthropic SSE event parsing.
//!
//! Anthropic Messages APIのSSEイベントをパースし、統一Event型に変換
//! Parse Anthropic Messages API SSE events and convert them to the unified Event type.
use crate::llm_client::{
ClientError,
@@ -14,7 +14,7 @@ use serde::Deserialize;
use super::AnthropicScheme;
use super::scheme_impl::{AnthropicState, PendingThinking};
/// Anthropic SSEイベントタイプ
/// Anthropic SSE event type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AnthropicEventType {
MessageStart,
@@ -28,7 +28,7 @@ pub(crate) enum AnthropicEventType {
}
impl AnthropicEventType {
/// イベントタイプ文字列からパース
/// Parse an event type string.
pub(crate) fn parse(s: &str) -> Option<Self> {
match s {
"message_start" => Some(Self::MessageStart),
@@ -45,10 +45,10 @@ impl AnthropicEventType {
}
// ============================================================================
// SSEイベントのJSON構造
// SSE event JSON structures
// ============================================================================
/// message_start イベント
/// message_start event.
#[derive(Debug, Deserialize)]
pub(crate) struct MessageStartEvent {
pub message: MessageStartMessage,
@@ -62,7 +62,7 @@ pub(crate) struct MessageStartMessage {
pub usage: Option<UsageData>,
}
/// content_block_start イベント
/// content_block_start event.
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockStartEvent {
pub index: usize,
@@ -79,15 +79,15 @@ pub(crate) enum ContentBlock {
Thinking {
#[serde(default)]
thinking: String,
/// 非ストリーミングレスポンス由来の初期 signature(通常はストリームでは
/// 空 → `signature_delta` で埋まる)。
/// Initial signature from a non-streaming response (usually empty in
/// streams, then filled by `signature_delta`).
#[serde(default)]
signature: Option<String>,
},
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// 暗号化された opaque blob。signature ではなく、まるごと
/// `redacted_thinking.data` として送り返す必要がある。
/// Encrypted opaque blob, not a signature. Must be sent back in full
/// as `redacted_thinking.data`.
#[serde(default)]
data: String,
},
@@ -99,7 +99,7 @@ pub(crate) enum ContentBlock {
},
}
/// content_block_delta イベント
/// content_block_delta event.
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockDeltaEvent {
pub index: usize,
@@ -120,13 +120,13 @@ pub(crate) enum DeltaBlock {
SignatureDelta { signature: String },
}
/// content_block_stop イベント
/// content_block_stop event.
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockStopEvent {
pub index: usize,
}
/// message_delta イベント
/// message_delta event.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct MessageDeltaEvent {
@@ -141,7 +141,7 @@ pub(crate) struct MessageDeltaData {
pub stop_sequence: Option<String>,
}
/// 使用量データ
/// Usage data.
#[derive(Debug, Deserialize)]
pub(crate) struct UsageData {
pub input_tokens: Option<u64>,
@@ -150,7 +150,7 @@ pub(crate) struct UsageData {
pub cache_creation_input_tokens: Option<u64>,
}
/// エラーイベント
/// Error event.
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorEventData {
pub error: ErrorDetail,
@@ -164,20 +164,20 @@ pub(crate) struct ErrorDetail {
}
// ============================================================================
// イベント変換
// Event conversion
// ============================================================================
impl AnthropicScheme {
/// SSEイベントをEvent型に変換
/// Convert an SSE event to Event.
///
/// # Arguments
/// * `event_type` - SSEイベントタイプ
/// * `data` - イベントデータJSON文字列
/// * `event_type` - SSE event type.
/// * `data` - Event data as a JSON string.
///
/// # Returns
/// * `Ok(Some(Event))` - 変換成功
/// * `Ok(None)` - イベントを無視(unknown event等)
/// * `Err(ClientError)` - パースエラー
/// * `Ok(Some(Event))` - Conversion succeeded.
/// * `Ok(None)` - Event ignored (e.g. an unknown event).
/// * `Err(ClientError)` - Parse error.
pub(crate) fn parse_event(
&self,
event_type: &str,
@@ -191,11 +191,11 @@ impl AnthropicScheme {
match event_type {
AnthropicEventType::MessageStart => {
let event: MessageStartEvent = serde_json::from_str(data)?;
// message_start時にUsageイベントがあれば出力
// Emit a Usage event at message_start if available.
if let Some(usage) = event.message.usage {
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
}
// Statusイベントとして開始を通知
// Notify start via a Status event.
Ok(Some(Event::Status(StatusEvent {
status: ResponseStatus::Started,
})))
@@ -210,18 +210,18 @@ impl AnthropicScheme {
}
AnthropicEventType::ContentBlockStop => {
let event: ContentBlockStopEvent = serde_json::from_str(data)?;
// Note: BlockStopにはblock_typeが必要だが、AnthropicはStopイベントに含めない
// Timeline層がBlockStartを追跡して正しいblock_typeを知る
// Note: BlockStop requires block_type, but Anthropic omits it from Stop events.
// The stateful parser tracks BlockStart to restore the correct block_type.
Ok(Some(Event::BlockStop(BlockStop {
index: event.index,
block_type: BlockType::Text, // Timeline層で上書きされる
block_type: BlockType::Text, // Placeholder for the stateful parser.
stop_reason: None,
reasoning: None,
})))
}
AnthropicEventType::MessageDelta => {
let event: MessageDeltaEvent = serde_json::from_str(data)?;
// Usage情報があれば出力
// Emit Usage information if available.
if let Some(usage) = event.usage {
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
}
@@ -271,7 +271,7 @@ impl AnthropicScheme {
DeltaContent::InputJson(partial_json.clone())
}
DeltaBlock::SignatureDelta { .. } => {
// signature_delta は無視
// Ignore signature_delta.
return None;
}
};
@@ -282,15 +282,14 @@ impl AnthropicScheme {
}))
}
/// state を持ち回す上位パース。
/// Higher-level parser carrying state across events.
///
/// `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
/// にだけ反映する)
/// In addition to the single Event from `parse_event`, it:
/// - Writes the preceding Start's `block_type` into `content_block_stop`.
/// - Accumulates the body, signature, and data of `thinking` /
/// `redacted_thinking` blocks in `state.pending_thinking` and attaches them
/// to Thinking BlockStop metadata at `content_block_stop`.
/// - Accumulates `signature_delta` only in reasoning metadata, not the Stream channel.
pub(crate) fn parse_with_state(
&self,
event_type: &str,
@@ -301,8 +300,8 @@ impl AnthropicScheme {
return Ok(Vec::new());
};
// signature_delta はストリーム表示には流さず、state にだけ蓄積。
// それ以外は parse_event で標準 Event 化する。
// Accumulate signature_delta in state only, not in the stream display.
// Convert everything else to standard Events via parse_event.
let mut emitted: Vec<Event> = Vec::new();
match parsed_event_type {
@@ -351,7 +350,7 @@ impl AnthropicScheme {
}
DeltaBlock::SignatureDelta { signature } => {
if let Some(pending) = state.pending_thinking.as_mut() {
// 通常 1 回しか来ないが、複数 fragment 来ても連結しておく
// Usually received once, but concatenate multiple fragments if present.
match &mut pending.signature {
Some(acc) => acc.push_str(signature),
None => pending.signature = Some(signature.clone()),
@@ -391,7 +390,7 @@ impl AnthropicScheme {
reasoning,
}));
}
// 残りは state を必要としない。既存 parse_event に委譲。
// The rest needs no state; delegate to the existing parse_event.
_ => {
if let Some(event) = self.parse_event(event_type, data)? {
emitted.push(event);
@@ -403,13 +402,13 @@ impl AnthropicScheme {
}
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`
// により非キャッシュ入力分は逆算可能。
// Anthropic's `input_tokens` counts only **uncached** input tokens.
// Full prompt length is input_tokens + cache_read + cache_creation.
// Add cached tokens here to follow UsageEvent's convention of reporting
// occupancy (full prompt length) in `input_tokens`.
// cache_read_input_tokens / cache_creation_input_tokens remain separate
// breakdown fields, so billing can recover uncached input with
// `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);
@@ -438,7 +437,7 @@ mod tests {
let event = scheme.parse_event("message_start", data).unwrap().unwrap();
match event {
Event::Usage(u) => {
// キャッシュなしなので input_total = raw_input = 10
// No cache, so input_total = raw_input = 10.
assert_eq!(u.input_tokens, Some(10));
}
_ => panic!("Expected Usage event"),
@@ -447,9 +446,9 @@ mod tests {
#[test]
fn test_convert_usage_includes_cache_in_input_total() {
// Anthropic の input_tokens はキャッシュ外のみで、占有量は
// input + cache_read + cache_creation。
// UsageEvent.input_tokens は占有量に正規化される。
// Anthropic's input_tokens counts only uncached tokens; occupancy is
// input + cache_read + cache_creation.
// UsageEvent.input_tokens is normalized to occupancy.
let scheme = AnthropicScheme::new();
let usage = UsageData {
input_tokens: Some(100),
@@ -531,7 +530,7 @@ mod tests {
#[test]
fn thinking_block_stop_carries_reasoning_with_signature() {
// thinking ブロックが完了したら reasoning metadata に text+signature が乗ること
// Completed thinking blocks carry text+signature in reasoning metadata.
let scheme = AnthropicScheme::new();
let mut state = AnthropicState::default();
@@ -1,7 +1,7 @@
//! Anthropic Messages API スキーマ
//! Anthropic Messages API scheme.
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
//! - Request JSON generation.
//! - SSE event parsing → Event conversion.
mod capability;
mod events;
@@ -10,14 +10,14 @@ mod scheme_impl;
pub use scheme_impl::AnthropicState;
/// Anthropicスキーマ
/// Anthropic scheme.
///
/// Anthropic Messages APIのリクエスト/レスポンス変換を担当
/// Handles request/response conversion for the Anthropic Messages API.
#[derive(Debug, Clone)]
pub struct AnthropicScheme {
/// APIバージョン
/// API version.
pub api_version: String,
/// 細粒度ツールストリーミングを有効にするか
/// Whether to enable fine-grained tool streaming.
pub fine_grained_tool_streaming: bool,
}
@@ -31,12 +31,12 @@ impl Default for AnthropicScheme {
}
impl AnthropicScheme {
/// 新しいスキーマを作成
/// Create a new scheme.
pub fn new() -> Self {
Self::default()
}
/// 細粒度ツールストリーミングを有効/無効にする
/// Enable or disable fine-grained tool streaming.
pub fn with_fine_grained_tool_streaming(mut self, enabled: bool) -> Self {
self.fine_grained_tool_streaming = enabled;
self
@@ -41,7 +41,7 @@ pub(crate) struct AnthropicRequest {
pub thinking: Option<AnthropicThinking>,
}
/// Anthropic extended thinking 指示。
/// Anthropic extended thinking instructions.
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum AnthropicThinking {
@@ -90,8 +90,7 @@ pub(crate) enum AnthropicContentPart {
},
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// 暗号化済み reasoning blob。`Item::Reasoning::encrypted_content`
/// から渡る。
/// Encrypted reasoning blob from `Item::Reasoning::encrypted_content`.
data: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
@@ -181,9 +180,9 @@ pub(crate) struct AnthropicTool {
impl AnthropicScheme {
/// Build Anthropic request from Request.
///
/// `capability.prompt_caching` が [`CacheStrategy::Auto`] のときは
/// `cache_control` マーカーを一切挿入しない(Ollama の `/v1/messages`
/// 流用時など、サーバ側が `cache_control` を受け付けないケース)。
/// No `cache_control` markers are inserted when `capability.prompt_caching`
/// is [`CacheStrategy::Auto`] (e.g. servers that reject `cache_control`,
/// such as Ollama when reusing `/v1/messages`).
pub(crate) fn build_request(
&self,
model: &str,
@@ -198,9 +197,8 @@ impl AnthropicScheme {
let messages = self.convert_items_to_messages(&request.items, &breakpoints);
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
// Reasoning の投影: capability が BudgetTokens / Both をサポート
// していて、request 側で budget_tokens が指定されているときだけ
// thinking フィールドを付ける。
// Project reasoning: add the thinking field only when the capability
// supports BudgetTokens / Both and the request specifies budget_tokens.
let supports_budget_tokens = matches!(
capability.reasoning,
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
@@ -361,23 +359,23 @@ impl AnthropicScheme {
..
} => {
flush_pending(&mut messages, &mut pending_user, "user", &mut locations);
// Anthropic はアシスタントターン中の `thinking` /
// `redacted_thinking` ブロックを必ず assistant role の
// content_part として送り返す必要がある。
// Anthropic requires `thinking` / `redacted_thinking` blocks
// from an assistant turn to be sent back as assistant-role
// content_parts.
//
// - signature あり: `thinking` content_part を投影
// - signature 無し + encrypted_content あり:
// `redacted_thinking` content_part を投影
// - どちらも無い: 他 scheme(OpenAI 等)から流入した
// 素の reasoning text。Anthropic に投げる意味も
// round-trip の根拠も無いので drop。
// - With signature: project a `thinking` content_part.
// - No signature, but encrypted_content present:
// project a `redacted_thinking` content_part.
// - Neither: plain reasoning text from another scheme
// (e.g. OpenAI). Drop it: there is no reason to send it to
// Anthropic and no basis for round-tripping it.
if let Some(sig) = signature.clone() {
pending_assistant
.push((i, AnthropicContentPart::thinking(text.clone(), sig)));
} else if let Some(data) = encrypted_content.clone() {
pending_assistant.push((i, AnthropicContentPart::redacted_thinking(data)));
}
// どちらも None なら何も pend せず、本 item は無視。
// If both are None, queue nothing and ignore this item.
}
}
}
@@ -486,7 +484,7 @@ mod tests {
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
};
/// cache_control が有効になる既定の capability。
/// Default capability with cache_control enabled.
fn cap_explicit() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
@@ -497,7 +495,7 @@ mod tests {
}
}
/// cache_control を送らない capability(Ollama 等)。
/// Capability that does not send cache_control (e.g. Ollama).
fn cap_auto() -> ModelCapability {
ModelCapability {
prompt_caching: CacheStrategy::Auto,
@@ -901,8 +899,8 @@ mod tests {
#[test]
fn cache_auto_does_not_add_cache_control() {
// Ollama のように `CacheStrategy::Auto` のときは cache_control
// マーカーを一切付けない。breakpoint 計算も走らないこと。
// With `CacheStrategy::Auto` (e.g. Ollama), add no cache_control markers.
// Breakpoint calculation must not run either.
let scheme = AnthropicScheme::new();
let mut request = Request::new().user("hello");
request.cache_anchor = Some(0);
@@ -933,8 +931,8 @@ mod tests {
#[test]
fn reasoning_with_signature_projects_thinking_part() {
// Item::Reasoning に signature があれば assistant role の
// `thinking` content_part として送る。
// Send Item::Reasoning with a signature as an assistant-role
// `thinking` content_part.
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("hi")
@@ -976,21 +974,21 @@ mod tests {
#[test]
fn reasoning_without_signature_or_encrypted_is_dropped() {
// 他 scheme から流入した素の reasoning は Anthropic に投げない。
// Do not send plain reasoning from other schemes to Anthropic.
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("hi")
.item(Item::reasoning("plain text"))
.item(Item::assistant_message("done"));
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
// thinking part は 1 つも乗らない
// No thinking parts are included.
assert!(collect_assistant_thinking_parts(&req).is_empty());
}
#[test]
fn thinking_part_lands_in_assistant_role_message() {
// wire 構造の position 検証: thinking part は assistant role の
// message 配列に並ぶ(user role には絶対に入らない)。
// Verify wire positions: thinking parts belong in the assistant-role
// message array (never in the user role).
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("question?")
@@ -1000,7 +998,7 @@ mod tests {
.user("follow up");
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
// 全 thinking part が assistant role の message に存在すること
// All thinking parts must be in assistant-role messages.
let mut thinking_msg_indices = Vec::new();
for (i, msg) in req.messages.iter().enumerate() {
if let AnthropicContent::Parts(parts) = &msg.content {
@@ -1023,9 +1021,9 @@ mod tests {
req.messages,
);
// thinking part を含む assistant message は、それに続く tool_use を含む
// assistant message より前 (= 先頭側) に位置すること
// (Anthropic 仕様: 同一論理ターン内で thinking → tool_use の順)
// Assistant messages containing thinking parts must precede the
// subsequent assistant messages containing tool_use.
// (Anthropic requires thinking → tool_use within one logical turn.)
let mut tool_use_msg_indices = Vec::new();
for (i, msg) in req.messages.iter().enumerate() {
if let AnthropicContent::Parts(parts) = &msg.content {
@@ -1050,7 +1048,7 @@ mod tests {
#[test]
fn redacted_thinking_part_lands_in_assistant_role_message() {
// RedactedThinking も同様に assistant role に置かれること。
// RedactedThinking must also be placed in the assistant role.
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("ask")
@@ -1,7 +1,7 @@
//! `impl Scheme for AnthropicScheme`
//!
//! Anthropic Messages API の wire 表現に必要な URL・ヘッダ・SSE パース・
//! リクエスト body 生成を共通 `Scheme` trait にぶら下げる。
//! Implements the URLs, headers, SSE parsing, and request body generation needed
//! for the Anthropic Messages API wire format through the shared `Scheme` trait.
use serde_json::Value;
@@ -17,22 +17,20 @@ use crate::llm_client::{
use super::AnthropicScheme;
/// Anthropic の SSE パースで必要な状態。
/// State required for Anthropic SSE parsing.
///
/// 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 永続化のため)。
/// 1. `content_block_stop` events lack `block_type`, so retain the type observed
/// in the preceding `content_block_start` and write it back into `BlockStop`.
/// 2. Accumulate `thinking_delta` text and `signature_delta` signatures from
/// `thinking` blocks, plus `data` from `redacted_thinking` blocks. Return them
/// as Thinking block metadata at `content_block_stop` for round-trip persistence.
#[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 の蓄積バッファ。
/// Accumulation buffer for one `thinking` or `redacted_thinking` content_block.
#[derive(Debug, Default)]
pub(crate) struct PendingThinking {
pub(crate) text: String,
@@ -64,11 +62,9 @@ impl Scheme for AnthropicScheme {
}
fn required_auth(&self) -> AuthRequirement {
// Ollama の `/v1/messages` 互換では認証が要らないが、それは
// `AuthRef::None` + `build_headers` 側の「ResolvedAuth::None
// なら何もしない」分岐で吸収する(`accepts` 判定で弾かれない
// よう、現状は XApiKey を要求しつつ、None 側でもパスするよう
// にする戦略)。
// Anthropic uses XApiKey. Compatible endpoints such as Ollama can opt out
// through ResolvedAuth::None, which the transport accepts for any auth
// requirement and handles without sending authentication headers.
AuthRequirement::XApiKey
}
@@ -1,14 +1,14 @@
//! Gemini scheme の wire-level 既定 capability。
//! Wire-level default capability for the Gemini scheme.
//!
//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は
//! client construction layer の責務。ここでは wire の
//! 保守的 default のみ。
//! Model ID tables (reasoning support by `gemini-*` version) belong to the
//! client construction layer. This module provides only conservative wire
//! defaults.
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
};
/// Scheme 既定の capability(未知モデル / 未明示モデル用)。
/// Scheme default capability (for unknown or unspecified models).
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
@@ -1,6 +1,6 @@
//! Gemini SSEイベントパース
//! Gemini SSE event parsing.
//!
//! Google Gemini APIのSSEイベントをパースし、統一Event型に変換
//! Parse Google Gemini API SSE events and convert them to the unified Event type.
use crate::llm_client::{
ClientError,
@@ -11,117 +11,117 @@ use serde::Deserialize;
use super::GeminiScheme;
// ============================================================================
// SSEイベントのJSON構造
// SSE event JSON structures
// ============================================================================
/// Gemini GenerateContentResponse (ストリーミングチャンク)
/// Gemini GenerateContentResponse (streaming chunk).
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateContentResponse {
/// 候補
/// Candidates.
pub candidates: Option<Vec<Candidate>>,
/// 使用量メタデータ
/// Usage metadata.
pub usage_metadata: Option<UsageMetadata>,
/// プロンプトフィードバック
/// Prompt feedback.
pub prompt_feedback: Option<PromptFeedback>,
/// モデルバージョン
/// Model version.
pub model_version: Option<String>,
}
/// 候補
/// Candidate.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Candidate {
/// コンテンツ
/// Content.
pub content: Option<CandidateContent>,
/// 完了理由
/// Finish reason.
pub finish_reason: Option<String>,
/// インデックス
/// Index.
pub index: Option<usize>,
/// 安全性評価
/// Safety rating.
pub safety_ratings: Option<Vec<SafetyRating>>,
}
/// 候補コンテンツ
/// Candidate content.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct CandidateContent {
/// パーツ
/// Parts.
pub parts: Option<Vec<CandidatePart>>,
/// ロール
/// Role.
pub role: Option<String>,
}
/// 候補パーツ
/// Candidate part.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CandidatePart {
/// テキスト
/// Text.
pub text: Option<String>,
/// 関数呼び出し
/// Function call.
pub function_call: Option<FunctionCall>,
}
/// 関数呼び出し
/// Function call.
#[derive(Debug, Deserialize)]
pub(crate) struct FunctionCall {
/// 関数名
/// Function name.
pub name: String,
/// 引数
/// Arguments.
pub args: Option<serde_json::Value>,
}
/// 使用量メタデータ
/// Usage metadata.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageMetadata {
/// プロンプトトークン数
/// Prompt token count.
pub prompt_token_count: Option<u64>,
/// 候補トークン数
/// Candidate token count.
pub candidates_token_count: Option<u64>,
/// 合計トークン数
/// Total token count.
pub total_token_count: Option<u64>,
}
/// プロンプトフィードバック
/// Prompt feedback.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PromptFeedback {
/// ブロック理由
/// Block reason.
pub block_reason: Option<String>,
/// 安全性評価
/// Safety rating.
pub safety_ratings: Option<Vec<SafetyRating>>,
}
/// 安全性評価
/// Safety rating.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct SafetyRating {
/// カテゴリ
/// Category.
pub category: Option<String>,
/// 確率
/// Probability.
pub probability: Option<String>,
}
// ============================================================================
// イベント変換
// Event conversion
// ============================================================================
impl GeminiScheme {
/// SSEデータをEvent型に変換
/// Convert SSE data to Event.
///
/// # Arguments
/// * `data` - SSEイベントデータJSON文字列
/// * `data` - SSE event data as a JSON string.
///
/// # Returns
/// * `Ok(Some(Vec<Event>))` - 変換成功
/// * `Ok(None)` - イベントを無視
/// * `Err(ClientError)` - パースエラー
/// * `Ok(Some(Vec<Event>))` - Conversion succeeded.
/// * `Ok(None)` - Event ignored.
/// * `Err(ClientError)` - Parse error.
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
// データが空または無効な場合はスキップ
// Skip empty or invalid data.
if data.is_empty() || data == "[DONE]" {
return Ok(None);
}
@@ -136,12 +136,12 @@ impl GeminiScheme {
let mut events = Vec::new();
// 使用量メタデータ
// Usage metadata.
if let Some(usage) = response.usage_metadata {
events.push(self.convert_usage(&usage));
}
// 候補を処理
// Process candidates.
if let Some(candidates) = response.candidates {
for candidate in candidates {
let candidate_index = candidate.index.unwrap_or(0);
@@ -149,26 +149,26 @@ impl GeminiScheme {
if let Some(content) = candidate.content {
if let Some(parts) = content.parts {
for (part_index, part) in parts.iter().enumerate() {
// テキストデルタ
// Text delta.
if let Some(text) = &part.text {
if !text.is_empty() {
// Geminiは明示的なBlockStartを送らないため、
// TextDeltaを直接送る(Timelineが暗黙的に開始を処理)
// Gemini sends no explicit BlockStart, so emit TextDelta
// directly (Timeline handles the implicit start).
events.push(Event::text_delta(part_index, text.clone()));
}
}
// 関数呼び出し
// Function call.
if let Some(function_call) = &part.function_call {
// 関数呼び出しの開始
// Geminiでは関数呼び出しは一度に送られることが多い
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
// Function call start.
// Gemini usually sends function calls all at once, but may
// send partial calls when streaming arguments are enabled.
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
// Gemini has no function call IDs, so use the name as the ID.
let function_id = format!("call_{}", function_call.name);
events.push(Event::BlockStart(BlockStart {
index: candidate_index * 10 + part_index, // 複合インデックス
index: candidate_index * 10 + part_index, // Composite index.
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: function_id,
@@ -176,7 +176,7 @@ impl GeminiScheme {
},
}));
// 引数がある場合はデルタとして送る
// Send arguments as a delta if present.
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" {
@@ -191,7 +191,7 @@ impl GeminiScheme {
}
}
// 完了理由
// Finish reason.
if let Some(finish_reason) = candidate.finish_reason {
let stop_reason = match finish_reason.as_str() {
"STOP" => Some(StopReason::EndTurn),
@@ -200,7 +200,7 @@ impl GeminiScheme {
_ => None,
};
// テキストブロックの停止
// Text block stop.
events.push(Event::BlockStop(BlockStop {
index: candidate_index,
block_type: BlockType::Text,
@@ -262,7 +262,7 @@ mod tests {
let events = scheme.parse_event(data).unwrap().unwrap();
// Usageイベントが含まれるはず
// Should contain a Usage event.
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
assert!(usage_event.is_some());
@@ -280,7 +280,7 @@ mod tests {
let events = scheme.parse_event(data).unwrap().unwrap();
// BlockStartイベントがあるはず
// Should contain a BlockStart event.
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
assert!(start_event.is_some());
@@ -293,7 +293,7 @@ mod tests {
}
}
// 引数デルタもあるはず
// Should also contain an argument delta.
let delta_event = events.iter().find(|e| {
if let Event::BlockDelta(d) = e {
matches!(d.delta, DeltaContent::InputJson(_))
@@ -311,7 +311,7 @@ mod tests {
let events = scheme.parse_event(data).unwrap().unwrap();
// BlockStopイベントがあるはず
// Should contain a BlockStop event.
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
assert!(stop_event.is_some());
@@ -1,29 +1,29 @@
//! Google Gemini API スキーマ
//! Google Gemini API scheme.
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
//! - Request JSON generation.
//! - SSE event parsing → Event conversion.
mod capability;
mod events;
mod request;
mod scheme_impl;
/// Geminiスキーマ
/// Gemini scheme.
///
/// Google Gemini APIのリクエスト/レスポンス変換を担当
/// Handles request/response conversion for the Google Gemini API.
#[derive(Debug, Clone, Default)]
pub struct GeminiScheme {
/// ストリーミング関数呼び出し引数を有効にするか
/// Whether to enable streaming function call arguments.
pub stream_function_call_arguments: bool,
}
impl GeminiScheme {
/// 新しいスキーマを作成
/// Create a new scheme.
pub fn new() -> Self {
Self::default()
}
/// ストリーミング関数呼び出し引数を有効/無効にする
/// Enable or disable streaming function call arguments.
pub fn with_stream_function_call_arguments(mut self, enabled: bool) -> Self {
self.stream_function_call_arguments = enabled;
self
@@ -140,12 +140,12 @@ pub(crate) struct GeminiGenerationConfig {
/// Stop sequences
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
/// Thinking / reasoning 設定(Gemini 2.5 以降)。
/// Thinking / reasoning settings (Gemini 2.5 and later).
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking_config: Option<GeminiThinkingConfig>,
}
/// Gemini thinking config (gemini-2.5 以降)
/// Gemini thinking config (gemini-2.5 and later).
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiThinkingConfig {
@@ -193,8 +193,8 @@ impl GeminiScheme {
None
};
// Reasoning の投影: capability が BudgetTokens / Both をサポートし、
// request 側で budget_tokens が指定されているときだけ thinking_config を付ける。
// Project reasoning: add thinking_config only when the capability
// supports BudgetTokens / Both and the request specifies budget_tokens.
let supports_budget = matches!(
capability.reasoning,
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
+34 -36
View File
@@ -1,11 +1,12 @@
//! APIスキーマ定義
//! API scheme definitions.
//!
//! 各APIスキーマごとの変換ロジック
//! - リクエスト変換: Request → プロバイダ固有JSON
//! - レスポンス変換: SSEイベント → Event
//! Conversion logic for each API scheme:
//! - Request conversion: Request → provider-specific JSON.
//! - Response conversion: SSE events → Event.
//!
//! [`Scheme`] trait により `HttpTransport<S>` から scheme 固有の差分
//! (パス、ヘッダ、認証要件、body 生成、SSE パース)をすべて委譲する。
//! The [`Scheme`] trait delegates all scheme-specific differences from
//! `HttpTransport<S>` (paths, headers, authentication requirements, body
//! generation, and SSE parsing).
pub mod anthropic;
pub mod gemini;
@@ -21,42 +22,40 @@ use super::error::ClientError;
use super::event::Event;
use super::types::{Request, RequestConfig};
/// wire scheme の抽象。各プロバイダの API 仕様ごとに 1 つ実装する。
/// Wire scheme abstraction, implemented once per provider API specification.
///
/// `HttpTransport<S: Scheme>` が URL 組立・認証ヘッダ挿入・SSE パース
/// のループを担い、`Scheme` 実装は各仕様固有の差分のみ提供する。
/// `HttpTransport<S: Scheme>` handles URL assembly, authentication header
/// insertion, and the SSE parsing loop; `Scheme` implementations supply only
/// specification-specific differences.
///
/// # 状態
/// # State
///
/// SSE パースでフレーム間に状態を保つ必要がある scheme(Anthropic の
/// `BlockStop` に `block_type` が載らない仕様の補完など)は
/// [`Scheme::State`] に中間状態を表す型を置く。
/// 状態を持たない scheme は `type State = ()` とする。
/// Schemes requiring state across SSE frames (e.g. to compensate for Anthropic
/// omitting `block_type` from `BlockStop`) define an intermediate state type in
/// [`Scheme::State`]. Stateless schemes use `type State = ()`.
pub trait Scheme: Clone + Send + Sync + 'static {
/// SSE パースのフレーム間で共有する状態。`HttpTransport` が
/// ストリーム開始時に `Default::default()` を一度だけ作り、
/// フレームごとに `&mut` で渡す。
/// State shared across SSE frames. `HttpTransport` creates it once via
/// `Default::default()` at stream start and passes it by `&mut` for each frame.
type State: Default + Send + 'static;
/// scheme のベース URL(`ModelConfig::base_url` 未指定時のデフォルト)
/// Default base URL for callers that do not supply an override.
fn default_base_url(&self) -> &'static str;
/// リクエスト先の相対パス。Gemini のようにモデル名をパスに埋め込む
/// プロバイダもあるため、モデル ID を受け取る。
/// Relative request path. Accepts a model ID because some providers, such as
/// Gemini, embed the model name in the path.
fn path(&self, model_id: &str) -> String;
/// この scheme が要求する認証形式。呼び出し側は client 構築時に
/// 設定された認証情報と照合する。
/// Authentication format required by this scheme. The caller checks it
/// against configured credentials when constructing the client.
fn required_auth(&self) -> AuthRequirement;
/// `Content-Type` 以外の追加ヘッダ。`anthropic-version` / `anthropic-beta` 等。
/// Additional headers beyond `Content-Type`, e.g. `anthropic-version` / `anthropic-beta`.
fn additional_headers(&self) -> Vec<(&'static str, String)> {
Vec::new()
}
/// リクエスト body を生成する。`capability` は `CacheStrategy` や
/// `ReasoningSupport` を参照して scheme 側の挙動を分岐させるため
/// に渡される。
/// Build the request body. `capability` lets the scheme select behavior
/// based on `CacheStrategy` and `ReasoningSupport`.
fn build_request_body(
&self,
model_id: &str,
@@ -64,11 +63,11 @@ pub trait Scheme: Clone + Send + Sync + 'static {
capability: &ModelCapability,
) -> Value;
/// SSE イベント 1 件を 0 個以上の [`Event`] に変換する。
/// Convert one SSE event to zero or more [`Event`]s.
///
/// `event_type` は SSE フレームの `event:` フィールド、`data` は
/// `data:` フィールド。`[DONE]` 等の終端マーカーは実装側で判定する。
/// `state` はストリーム単位で共有される可変状態。
/// `event_type` is the SSE frame's `event:` field; `data` is its `data:` field.
/// Implementations detect terminal markers such as `[DONE]`.
/// `state` is mutable state shared for the stream.
fn parse_sse(
&self,
event_type: &str,
@@ -76,9 +75,9 @@ pub trait Scheme: Clone + Send + Sync + 'static {
state: &mut Self::State,
) -> Result<Vec<Event>, ClientError>;
/// scheme 既定の capability。モデル ID に関係なく、この wire で
/// 安全に送れる最小共通項を返す。既知モデル ID の能力テーブルは
/// 高レベルの client 構築層の責務で、scheme はここには関与しない。
/// Scheme default capability: the minimum common features safe to send over
/// this wire, regardless of model ID. Known-model capability tables belong
/// to the higher-level client construction layer, not the scheme.
fn default_capability(&self) -> ModelCapability;
/// Strongest safe completion boundary for streamed tool calls.
@@ -86,9 +85,8 @@ pub trait Scheme: Clone + Send + Sync + 'static {
ToolCallCompletionSupport::ResponseComplete
}
/// scheme 側でサポートしていない `RequestConfig` フィールドを
/// 警告として返す(例: OpenAI Chat は `top_k` 非対応)。
/// デフォルトは空 Vec。
/// Return warnings for `RequestConfig` fields unsupported by the scheme
/// (e.g. OpenAI Chat does not support `top_k`). Defaults to an empty Vec.
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
let _ = config;
Vec::new()
@@ -1,14 +1,14 @@
//! OpenAI Chat Completions scheme の wire-level 既定 capability。
//! Wire-level default capability for the OpenAI Chat Completions scheme.
//!
//! モデル ID 固有のテーブル(`gpt-5` 系など)は client construction layer
//! の責務。ここでは wire の保守的 default のみ。
//! Model ID tables (e.g. the `gpt-5` family) belong to the client construction
//! layer. This module provides only conservative wire defaults.
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
};
/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等)
/// で未知モデル ID を受けたときのフォールバックに使う。
/// Scheme default capability, used as a fallback for unknown model IDs on
/// OpenAI-compatible routers (xAI / Groq / OpenRouter, etc.).
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
@@ -1,4 +1,4 @@
//! OpenAI SSEイベントパース
//! OpenAI SSE event parsing.
use crate::llm_client::{
ClientError,
@@ -61,10 +61,10 @@ pub(crate) struct ChunkUsage {
}
impl OpenAIScheme {
/// SSEデータのパースとEventへの変換
/// Parse SSE data and convert it to Event.
///
/// OpenAI APIはBlockStartイベントを明示的に送信しない。
/// Timeline層が暗黙的なBlockStartを処理する。
/// The OpenAI API does not explicitly send BlockStart events.
/// The Timeline layer handles implicit BlockStart.
pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
if data == "[DONE]" {
return Ok(None);
@@ -94,8 +94,8 @@ impl OpenAIScheme {
for choice in chunk.choices {
// Text Content Delta
if let Some(content) = choice.delta.content {
// OpenAI APIはBlockStartを送らないため、デルタのみを発行
// Timeline層が暗黙的なBlockStartを処理する
// Emit only deltas because the OpenAI API sends no BlockStart.
// The Timeline layer handles implicit BlockStart.
events.push(Event::text_delta(choice.index, content));
}
@@ -136,11 +136,11 @@ impl OpenAIScheme {
finish_reason == "tool_calls" || finish_reason == "function_call";
if is_tool_finish {
// ツール呼び出し終了
// Note: OpenAIはどのツールが終了したか明示しないため、
// Timeline層で適切に処理する必要がある
// Tool call ended.
// Note: OpenAI does not specify which tool ended, so the
// Timeline layer must handle this appropriately.
} else {
// テキスト終了
// Text ended.
events.push(Event::text_block_stop(choice.index, stop_reason));
}
}
@@ -165,7 +165,7 @@ mod tests {
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を発行しないため、デルタのみ
// Deltas only, since OpenAI does not emit BlockStart.
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
@@ -1,31 +1,32 @@
//! OpenAI Chat Completions API スキーマ
//! OpenAI Chat Completions API scheme.
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
//! - Request JSON generation.
//! - SSE event parsing → Event conversion.
pub(crate) mod capability;
mod events;
mod request;
mod scheme_impl;
/// OpenAIスキーマ
/// OpenAI scheme.
///
/// OpenAI Chat Completions API (および互換API) のリクエスト/レスポンス変換を担当
/// Handles request/response conversion for the OpenAI Chat Completions API
/// (and compatible APIs).
#[derive(Debug, Clone, Default)]
pub struct OpenAIScheme {
/// モデル名 (リクエスト時に指定されるが、デフォルト値として保持も可能)
/// Model name (supplied per request, but may also be stored as a default).
pub model: Option<String>,
/// レガシーなmax_tokensを使用するか (Ollama互換用)
/// Whether to use legacy max_tokens (for Ollama compatibility).
pub use_legacy_max_tokens: bool,
}
impl OpenAIScheme {
/// 新しいスキーマを作成
/// Create a new scheme.
pub fn new() -> Self {
Self::default()
}
/// レガシーなmax_tokensを使用するか設定
/// Set whether to use legacy max_tokens.
pub fn with_legacy_max_tokens(mut self, use_legacy: bool) -> Self {
self.use_legacy_max_tokens = use_legacy;
self
@@ -38,7 +38,7 @@ pub(crate) struct OpenAIRequest {
pub tools: Vec<OpenAITool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<String>,
/// Reasoning effort(o1 / o3 / o4 / gpt-5 系で有効)。
/// Reasoning effort (supported by the o1 / o3 / o4 / gpt-5 families).
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}
@@ -147,8 +147,8 @@ impl OpenAIScheme {
(None, request.config.max_tokens)
};
// Reasoning の投影: capability が Effort / Both をサポートし、
// request 側で effort が指定されているときだけ reasoning_effort を付ける。
// Project reasoning: add reasoning_effort only when the capability
// supports Effort / Both and the request specifies an effort.
let supports_effort = matches!(
capability.reasoning,
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
@@ -45,7 +45,7 @@ impl Scheme for OpenAIScheme {
data: &str,
_state: &mut Self::State,
) -> Result<Vec<Event>, ClientError> {
// `data: [DONE]` は終端マーカー
// `data: [DONE]` is a terminal marker.
if data.trim() == "[DONE]" {
return Ok(Vec::new());
}
@@ -58,7 +58,7 @@ impl Scheme for OpenAIScheme {
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
// OpenAI Chat Completions API は top_k を受け付けない
// The OpenAI Chat Completions API does not accept top_k.
if config.top_k.is_some() {
warnings.push(ConfigWarning::unsupported("top_k", "OpenAI Chat"));
}
@@ -1,7 +1,7 @@
//! OpenAI Responses scheme の wire-level 既定 capability。
//! Wire-level default capability for the OpenAI Responses scheme.
//!
//! モデル ID 固有の能力テーブルは高レベルの client 構築層の責務。
//! ここでは wire の保守的 default のみ。
//! Model ID capability tables belong to the higher-level client construction
//! layer. This module provides only conservative wire defaults.
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
@@ -1,9 +1,9 @@
//! OpenAI Responses API の SSE イベントパース
//! OpenAI Responses API SSE event parsing.
//!
//! `response.*` 名前空間の SSE を共通の [`Event`](crate::llm_client::event::Event)
//! に変換する。Responses の (output_index, content_index) 2 次元座標と
//! この crate の 1 次元 `BlockStart/Delta/Stop::index` のマッピングは
//! [`OpenAIResponsesState`] が保持する。
//! Converts SSE events in the `response.*` namespace to shared [`Event`]s.
//! [`OpenAIResponsesState`] holds the mapping from Responses' two-dimensional
//! (output_index, content_index) coordinates to this crate's one-dimensional
//! `BlockStart/Delta/Stop::index`.
use std::collections::{BTreeMap, HashMap};
@@ -18,15 +18,16 @@ use crate::llm_client::{
},
};
/// SSE パース中の座標 → flat block index マップ。
/// Coordinate → flat block index map used during SSE parsing.
#[derive(Debug, Default)]
pub struct OpenAIResponsesState {
slots: HashMap<SlotKey, SlotInfo>,
next_index: usize,
/// 蓄積中の reasoning output_item。`output_item.added`(Reasoning) で
/// 確保し、`reasoning_text.delta` / `reasoning_summary_text.delta` で
/// 蓄積、`output_item.done`(Reasoning) で既存 reasoning_text block または
/// metadata-only Thinking block に reasoning persistence material を載せる。
/// Reasoning output_items being accumulated. Allocated at
/// `output_item.added` (Reasoning), filled by `reasoning_text.delta` /
/// `reasoning_summary_text.delta`, then attached as reasoning persistence
/// material to an existing reasoning_text block or metadata-only Thinking
/// block at `output_item.done` (Reasoning).
pending_reasoning: HashMap<usize, PendingReasoning>,
/// Tool input deltas and authoritative final values, keyed by output index.
pending_tool_inputs: HashMap<usize, PendingToolInput>,
@@ -67,19 +68,19 @@ impl ToolInputKind {
}
}
/// 1 つの reasoning output_item の蓄積バッファ。
/// Accumulation buffer for one reasoning output_item.
#[derive(Debug, Default)]
struct PendingReasoning {
id: Option<String>,
/// `reasoning_text.delta` の累積。複数 content_part あれば順に concat。
/// Accumulated `reasoning_text.delta`, concatenating multiple content_parts in order.
text: String,
/// `reasoning_summary_text.delta` を summary_index 順に蓄積。
/// Accumulates `reasoning_summary_text.delta` in summary_index order.
summary: Vec<String>,
/// `response.content_part.done` が先に到着した reasoning/thinking block。
/// Reasoning/thinking blocks whose `response.content_part.done` arrived first.
///
/// `response.output_item.done` まで待たないと encrypted_content や最終
/// summary が揃わないため、live-visible な余分な synthetic Thinking block
/// を作らず、既存 block の stop に persistence metadata を載せる。
/// encrypted_content and final summaries are not complete until
/// `response.output_item.done`. Attach persistence metadata to the existing
/// block's stop rather than creating an extra live-visible synthetic Thinking block.
deferred_thinking_stops: Vec<SlotInfo>,
}
@@ -106,9 +107,9 @@ impl OpenAIResponsesState {
info
}
/// 既存 slot を取得。無ければ `block_type` で暗黙に確保し、
/// 新規確保したかを併せて返す。delta 先行 / content_part.added が
/// 抜けたときの防御。
/// Get an existing slot or implicitly allocate one with `block_type`, also
/// returning whether it was newly allocated. Defends against deltas arriving
/// first or missing content_part.added events.
fn get_or_allocate(&mut self, key: SlotKey, block_type: BlockType) -> (SlotInfo, bool) {
if let Some(info) = self.slots.get(&key).copied() {
(info, false)
@@ -157,11 +158,11 @@ impl OpenAIResponsesState {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum SlotKey {
/// tool_use (function_call / custom_tool_call) — output_item 全体で 1 block
/// tool_use (function_call / custom_tool_call) — one block for the entire output_item.
OutputItem(usize),
/// message の output_text / reasoning item の reasoning_text
/// Message output_text / reasoning item reasoning_text.
ContentPart { output: usize, content: usize },
/// reasoning item の summary_text (summary_index)
/// Reasoning item summary_text (summary_index).
Summary { output: usize, summary: usize },
}
@@ -172,7 +173,7 @@ struct SlotInfo {
}
// ============================================================================
// SSE イベントの JSON 構造
// SSE event JSON structures
// ============================================================================
#[derive(Debug, Deserialize)]
@@ -188,7 +189,7 @@ struct OutputItemDone {
item: OutputItem,
}
/// `response.output_item.added/done` の `item`。
/// `item` in `response.output_item.added/done`.
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum OutputItem {
@@ -199,8 +200,8 @@ enum OutputItem {
Reasoning {
#[serde(default)]
id: Option<String>,
/// `output_item.done` で初めて埋まる。`include=["reasoning.encrypted_content"]`
/// 指定時に opaque blob が乗る。
/// First populated at `output_item.done`. Carries an opaque blob when
/// `include=["reasoning.encrypted_content"]` is specified.
#[serde(default)]
encrypted_content: Option<String>,
},
@@ -241,7 +242,7 @@ struct ContentPartDone {
part: ContentPart,
}
/// `response.content_part.added/done` の `part`。
/// `part` in `response.content_part.added/done`.
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentPart {
@@ -338,7 +339,7 @@ struct ResponsesUsage {
output_tokens: Option<u64>,
#[serde(default)]
total_tokens: Option<u64>,
/// `input_tokens` の内訳。`cached_tokens` がプロンプトキャッシュヒット分。
/// Breakdown of `input_tokens`; `cached_tokens` counts prompt cache hits.
#[serde(default)]
input_tokens_details: Option<InputTokensDetails>,
}
@@ -399,11 +400,11 @@ struct TopLevelError {
// parse entry point
// ============================================================================
/// SSE フレーム 1 件をパースし、0 個以上の [`Event`] に変換する。
/// Parse one SSE frame and convert it to zero or more [`Event`]s.
///
/// `event_type` は SSE の `event:` フィールド。未対応の event type は
/// [`Event::UnhandledSse`] として観測可能にする。`data` が JSON でない /
/// 必要なフィールドが抜けている等は [`ClientError::Api`] で返す。
/// `event_type` is the SSE `event:` field. Unsupported event types are observable
/// as [`Event::UnhandledSse`]. Non-JSON `data`, missing required fields, and similar
/// failures return [`ClientError::Api`].
pub(crate) fn parse_sse(
event_type: &str,
data: &str,
@@ -427,7 +428,7 @@ pub(crate) fn parse_sse(
cache_read_input_tokens: usage
.input_tokens_details
.and_then(|d| d.cached_tokens),
// Responses API は cache 書き込みを別計上しない(input_tokens に含まれる)
// The Responses API does not account for cache writes separately (included in input_tokens).
cache_creation_input_tokens: None,
}));
}
@@ -466,8 +467,8 @@ pub(crate) fn parse_sse(
name,
),
OutputItem::Reasoning { id, .. } => {
// wrapper を確保。中身の content_part / summary_part は
// 別 SlotKey で扱われ続ける(Streaming 表示は維持)。
// Allocate the wrapper. Its content_parts / summary_parts continue
// to use separate SlotKeys (preserving the streaming display).
let entry = state.ensure_reasoning(ev.output_index);
if id.is_some() {
entry.id = id;
@@ -480,10 +481,10 @@ pub(crate) fn parse_sse(
"response.output_item.done" => {
let ev: OutputItemDone = from_json(data)?;
// Reasoning wrapper の done で蓄積分を既存 reasoning_text block の
// stop に載せる。content_part.done が先に来た場合は stop を defer
// しておき、ここで encrypted_content / summary と一緒に完了させる。
// reasoning_text が無い metadata-only item だけ synthetic block を作る。
// On Reasoning wrapper done, attach accumulated material to the existing
// reasoning_text block's stop. If content_part.done arrived first, defer
// its stop and complete it here with encrypted_content / summary.
// Create synthetic blocks only for metadata-only items without reasoning_text.
if let OutputItem::Reasoning {
id,
encrypted_content,
@@ -638,7 +639,7 @@ pub(crate) fn parse_sse(
"response.reasoning_text.delta" => {
let ev: ReasoningTextDelta = from_json(data)?;
// round-trip 用に蓄積
// Accumulate for round-tripping.
state
.ensure_reasoning(ev.output_index)
.text
@@ -673,7 +674,7 @@ pub(crate) fn parse_sse(
"response.reasoning_summary_text.delta" => {
let ev: ReasoningSummaryTextDelta = from_json(data)?;
// round-trip 用に蓄積
// Accumulate for round-tripping.
state.extend_reasoning_summary(ev.output_index, ev.summary_index, &ev.delta);
Ok(ensure_and_delta(
state,
@@ -782,7 +783,7 @@ pub(crate) fn parse_sse(
Ok(vec![Event::Error(ErrorEvent { code, message })])
}
// 未対応 / 情報系 event type は生成 semantics からは無視しつつ trace に残す。
// Ignore unsupported / informational event types for generation semantics, but retain them in traces.
_ => Ok(vec![unhandled_sse_event(event_type, data)]),
}
}
@@ -1103,8 +1104,8 @@ fn append_diagnostic(message: String, diagnostic: Map<String, Value>) -> String
format!("{} | diagnostic={}", message, Value::Object(diagnostic))
}
/// 対応する BlockStart がまだ発行されていなければ発行しつつ、delta を流す。
/// content_part.added を取りこぼしても delta 単独で復旧できるようにする。
/// Emit a delta, first emitting its BlockStart if not already sent.
/// Allows recovery from a delta alone when content_part.added was missed.
fn ensure_and_delta(
state: &mut OpenAIResponsesState,
key: SlotKey,
@@ -1219,14 +1220,14 @@ mod tests {
assert_eq!(u.output_tokens, Some(50));
assert_eq!(u.total_tokens, Some(12395));
assert_eq!(u.cache_read_input_tokens, Some(11000));
// OpenAI Responses は cache 書き込みを別計上しない
// OpenAI Responses does not account for cache writes separately.
assert_eq!(u.cache_creation_input_tokens, None);
}
#[test]
fn text_stream_start_delta_stop() {
let mut state = OpenAIResponsesState::default();
// output_item.added (message) → 無視
// output_item.added (message) → ignored.
with(
&mut state,
"response.output_item.added",
@@ -1537,7 +1538,7 @@ mod tests {
#[test]
fn delta_without_prior_start_recovers() {
// 防御: content_part.added が落ちても delta 単独で BlockStart+Delta を発行
// Defensive: emit BlockStart+Delta from a delta alone if content_part.added was lost.
let mut state = OpenAIResponsesState::default();
let ev = with(
&mut state,
@@ -1551,8 +1552,8 @@ mod tests {
#[test]
fn parallel_output_items_get_distinct_indices() {
// 2 つの function_call が並列で output_item.added される場合、
// flat index が別々になる(Parallel tool calling の基本)。
// Two parallel function_call output_item.added events get distinct
// flat indices (the basis of parallel tool calling).
let mut state = OpenAIResponsesState::default();
let ev1 = with(
&mut state,
@@ -1675,17 +1676,17 @@ mod tests {
#[test]
fn reasoning_output_item_completes_metadata_thinking_block_with_text_summary_encrypted() {
// 完成済み reasoning wrapper が text + summary[] + encrypted_content を持って
// Thinking BlockStop metadata として届くこと。
// A completed reasoning wrapper must deliver text + summary[] +
// encrypted_content as Thinking BlockStop metadata.
let mut state = OpenAIResponsesState::default();
// wrapper added (id だけ持つ)
// Wrapper added (with only an id).
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1"}}"#,
);
// 内側の reasoning_text 用 content_part
// Inner content_part for reasoning_text.
let start = with(
&mut state,
"response.content_part.added",
@@ -1711,7 +1712,7 @@ mod tests {
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":"hello world"}}"#,
);
assert!(part_done.is_empty());
// summary 1 件
// One summary.
with(
&mut state,
"response.reasoning_summary_part.added",
@@ -1728,7 +1729,7 @@ mod tests {
r#"{"output_index":0,"summary_index":0,"item_id":"r1"}"#,
);
// wrapper done (encrypted_content が乗る)
// Wrapper done (includes encrypted_content).
let evs = with(
&mut state,
"response.output_item.done",
@@ -1745,7 +1746,7 @@ mod tests {
assert_eq!(reasoning.summary, vec!["sum-A".to_string()]);
assert_eq!(reasoning.encrypted_content.as_deref(), Some("ENC-XYZ"));
assert!(reasoning.signature.is_none());
// pending_reasoning は drain されていること
// pending_reasoning must be drained.
assert!(state.pending_reasoning.is_empty());
}
@@ -1835,8 +1836,8 @@ mod tests {
#[test]
fn reasoning_wrapper_without_inner_content_emits_empty_text() {
// encrypted_content だけ届く(reasoning_text 無し)ケースでも
// reasoning metadata は届く。
// Reasoning metadata must arrive even when only encrypted_content
// is received (no reasoning_text).
let mut state = OpenAIResponsesState::default();
with(
&mut state,
@@ -1,11 +1,11 @@
//! OpenAI Responses API スキーマ (`/v1/responses`)
//! OpenAI Responses API scheme (`/v1/responses`).
//!
//! Chat Completions とは別物の item-based wire format。reasoning item と
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
//! 流れる。
//! An item-based wire format distinct from Chat Completions. Reasoning and
//! function_call items are first-class, and SSE events use the `response.*`
//! namespace.
//!
//! - リクエスト JSON 生成: `request`
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: `events`
//! - Request JSON generation: `request`.
//! - SSE event parsing → [`Event`](crate::llm_client::event::Event) conversion: `events`.
mod capability;
mod events;
@@ -14,29 +14,29 @@ mod scheme_impl;
pub use scheme_impl::OpenAIResponsesState;
/// OpenAI Responses scheme 本体。
/// 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)。
/// `send_sampling_params` are scheme-level wire settings. Defaults target the
/// official OpenAI Responses API (stateless + ZDR, with `max_output_tokens` /
/// `temperature` / `top_p` allowed). For compatible backends accepting only a
/// subset of parameters, the client construction layer overrides
/// `send_max_output_tokens=false` / `send_sampling_params=false`.
/// These do not belong in `ModelCapability`: they are wire policy, not model capabilities.
#[derive(Debug, Clone)]
pub struct OpenAIResponsesScheme {
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
/// Whether to store responses on the server. `false` for ZDR/stateless operation.
pub store: bool,
/// `include: ["reasoning.encrypted_content"]` を付けるか。
/// `store=false` で reasoning を使うなら必須。
/// Whether to add `include: ["reasoning.encrypted_content"]`.
/// Required when using reasoning with `store=false`.
pub include_encrypted_content: bool,
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
/// 受理するが、互換 backend によっては `Unsupported parameter` で
/// 400 を返すため、その経路では `false` にする。
/// Whether to include `max_output_tokens` in the body. The official OpenAI
/// Responses API accepts it, but some compatible backends return 400 with
/// `Unsupported parameter`; set this to `false` for those routes.
pub send_max_output_tokens: bool,
/// `temperature` / `top_p` を body に載せるか。公式 OpenAI Responses API
/// は受理するが、互換 backend によっては `Unsupported parameter` で
/// 400 を返すため、その経路では `false` にする。
/// Whether to include `temperature` / `top_p` in the body. The official OpenAI
/// Responses API accepts them, but some compatible backends return 400 with
/// `Unsupported parameter`; set this to `false` for those routes.
pub send_sampling_params: bool,
}
@@ -52,31 +52,31 @@ impl Default for OpenAIResponsesScheme {
}
impl OpenAIResponsesScheme {
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
/// `send_max_output_tokens=true`, `send_sampling_params=true`)。
/// Default settings (`store=false`, `include=["reasoning.encrypted_content"]`,
/// `send_max_output_tokens=true`, `send_sampling_params=true`).
pub fn new() -> Self {
Self::default()
}
/// `store` を上書き。
/// Override `store`.
pub fn with_store(mut self, store: bool) -> Self {
self.store = store;
self
}
/// `include: ["reasoning.encrypted_content"]` の有無を上書き。
/// Override whether to include `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 に載せるかを上書き。
/// Override whether to include `max_output_tokens` in the body.
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
self.send_max_output_tokens = send;
self
}
/// `temperature` / `top_p` を body に載せるかを上書き。
/// Override whether to include `temperature` / `top_p` in the body.
pub fn with_send_sampling_params(mut self, send: bool) -> Self {
self.send_sampling_params = send;
self
@@ -1,8 +1,8 @@
//! OpenAI Responses API リクエスト body 生成
//! OpenAI Responses API request body generation.
//!
//! Chat Completions の `messages` と違い、Responses は `input[]` の
//! item 配列で reasoning / function_call / function_call_output が
//! first-class。`Item` を素に近い形で `input[]` に投影できる。
//! Unlike Chat Completions `messages`, Responses uses an `input[]` item array
//! with first-class reasoning / function_call / function_call_output items.
//! `Item` can be projected into `input[]` with little transformation.
use serde::{Serialize, Serializer};
use serde_json::Value;
@@ -32,93 +32,92 @@ pub(crate) enum FunctionCallOutputContentItem {
InputImage { image_url: String },
}
/// `/v1/responses` のリクエスト body。
/// Request body for `/v1/responses`.
#[derive(Debug, Serialize)]
pub(crate) struct ResponsesRequest {
pub model: String,
/// システムプロンプト相当。`input[]` とは別フィールド。
/// System prompt equivalent, separate from `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 固定値。
/// Always sends `"auto"`, a scheme-level constant.
pub tool_choice: &'static str,
/// 常時 `true` を送る。scheme 固定値。
/// Always sends `true`, a scheme-level constant.
pub parallel_tool_calls: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
/// ZDR / stateless 運用では `false`。
/// `false` for ZDR / stateless operation.
pub store: bool,
/// 常時 `true`。
/// Always `true`.
pub stream: bool,
/// `["reasoning.encrypted_content"]` 等。
/// E.g. `["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 で除外)。
/// Accepted by the official OpenAI Responses API, but some compatible backends
/// reject it with 400. When the scheme's `send_max_output_tokens` is `false`,
/// leave it as `None` (excluded by 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` のまま送る。
/// Accepted by the official OpenAI Responses API, but some compatible backends
/// reject `temperature` / `top_p` with 400. When the scheme's
/// `send_sampling_params` is `false`, leave them as `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` のときはキー自体を送らない。
/// Stable conversation key. For backends requiring an explicit key, the caller
/// supplies a stable conversation identifier. The key is omitted entirely
/// when `Request::cache_key` is `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
}
/// reasoning 制御。
/// Reasoning control.
#[derive(Debug, Serialize)]
pub(crate) struct ReasoningConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
/// Summary output control, fixed to `"auto"` to receive summary_text.
pub summary: &'static str,
}
/// `input[]` の 1 要素。
/// One element of `input[]`.
///
/// Responses API の item 型を素に近い形で投影する。未対応 type は
/// 無視(reasoning 送信時に `content: []` の場合は `None` として弾く)。
/// Projects Responses API item types with little transformation. Unsupported types
/// are ignored (reasoning with `content: []` is rejected as `None` when sending).
#[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"` を使う。
/// Conversation message: user / assistant / developer.
/// `Role::System` items are projected as `developer`. Some OpenAI Responses
/// compatible backends reject `role: "system"`, so system-equivalent
/// insertions use `role: "developer"`.
Message {
role: &'static str,
content: Vec<InputContent>,
},
/// 過去の function tool 呼び出し(assistant 側)。
/// Previous function tool call (assistant side).
FunctionCall {
call_id: String,
name: String,
/// JSON 文字列(object でなくても正規化済み)。
/// JSON string (normalized even if the original was not an object).
arguments: String,
},
/// function tool の結果(user 側)。
/// Function tool result (user side).
FunctionCallOutput {
call_id: String,
output: FunctionCallOutputBody,
},
/// reasoning item。`encrypted_content` があれば必ず添える。
/// Reasoning item, always including `encrypted_content` when present.
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'" で弾かれる。
/// The Responses API requires `summary` on reasoning items, even if sent
/// as an empty `[]`. Models such as GPT-5 may return no summary text on
/// turns without a specified reasoning effort. Omitting this field then
/// causes a 400 `"Missing required parameter: 'input[N].summary'"` rejection.
summary: Vec<ReasoningSummaryPart>,
#[serde(skip_serializing_if = "Vec::is_empty")]
content: Vec<ReasoningContentPart>,
@@ -127,14 +126,13 @@ pub(crate) enum InputItem {
},
}
/// メッセージ content_part。role で input/output を使い分ける。
/// Message content_part, using input/output according to role.
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum InputContent {
/// user / developer 側のテキスト
/// User / developer text.
InputText { text: String },
/// user 側の画像
/// assistant 側のテキスト
/// Assistant text.
OutputText { text: String },
}
@@ -150,8 +148,8 @@ pub(crate) enum ReasoningContentPart {
ReasoningText { text: String },
}
/// Responses 用 tool 定義。Chat と違い function キーでネストせず
/// トップレベルに `name` / `parameters` が載る。
/// Tool definition for Responses. Unlike Chat, `name` / `parameters` are
/// top-level fields, not nested under a function key.
#[derive(Debug, Serialize)]
pub(crate) struct ResponseTool {
#[serde(rename = "type")]
@@ -159,13 +157,12 @@ pub(crate) struct ResponseTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// OpenAI Responses API は `type:"object"` のパラメータスキーマに
/// `properties` が存在することを要求する。schemars は引数なし struct
/// から `properties` を含まない最小スキーマを出すので、serialize
/// 時に空オブジェクトを補う。
/// The OpenAI Responses API requires `properties` in `type:"object"` parameter
/// schemas. schemars emits minimal schemas without `properties` for structs
/// with no arguments, so serialization supplies an empty object.
#[serde(serialize_with = "serialize_parameters")]
pub parameters: Value,
/// Structured output モード制御。デフォルト false。
/// Structured output mode control. Defaults to false.
pub strict: bool,
}
@@ -182,7 +179,7 @@ fn serialize_parameters<S: Serializer>(value: &Value, s: S) -> Result<S::Ok, S::
}
impl OpenAIResponsesScheme {
/// `Request` から wire 形式の body を組み立てる。
/// Build a wire-format body from `Request`.
pub(crate) fn build_request(
&self,
model: &str,
@@ -192,8 +189,8 @@ impl OpenAIResponsesScheme {
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 を付ける。
// Project reasoning: add reasoning only when the capability supports
// Effort / Both and the request specifies an effort.
let supports_effort = matches!(
capability.reasoning,
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
@@ -249,7 +246,7 @@ impl OpenAIResponsesScheme {
}
}
/// `Item` 列を `input[]` に変換する。
/// Convert an `Item` sequence to `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 {
@@ -280,7 +277,7 @@ fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputIte
arguments,
..
} => {
// 非 object / 旧形式の "null" を "{}" に正規化。
// Normalize non-objects / legacy "null" to "{}".
let normalized = parse_tool_arguments(arguments).to_string();
out.push(InputItem::FunctionCall {
call_id: call_id.clone(),
@@ -554,11 +551,10 @@ mod tests {
#[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 しないこと。
// The Responses API requires `summary` on reasoning items. Even an empty
// summary must remain as `summary: []` on the wire, or some backends
// reject it as a missing required parameter. Turns without a specified
// reasoning effort may have no summary text; do not skip empty summaries.
let scheme = OpenAIResponsesScheme::new();
let item = Item::reasoning("").with_encrypted_content("ENC");
let req = Request::new().user("hi").item(item);
@@ -667,9 +663,9 @@ mod tests {
#[test]
fn tool_schema_without_properties_is_normalized() {
// schemars は引数なし struct から `type:"object"` だけのスキーマを
// 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので
// 送る直前に空オブジェクトを補うのを確認。
// schemars emits only `type:"object"` for structs with no arguments.
// OpenAI Responses rejects missing `properties` with 400, so verify that
// an empty object is supplied just before sending.
let scheme = OpenAIResponsesScheme::new();
let raw_schema = serde_json::json!({ "type": "object" });
let req = Request::new().tool(
@@ -707,7 +703,7 @@ mod tests {
#[test]
fn serialized_body_has_expected_shape() {
// wire 形式が崩れていないかのスモークテスト
// Smoke test for an intact wire format.
let scheme = OpenAIResponsesScheme::new();
let req = Request::new()
.system("sys")
@@ -20,8 +20,8 @@ impl Scheme for OpenAIResponsesScheme {
type State = OpenAIResponsesState;
fn default_base_url(&self) -> &'static str {
// `/v1` は base_url 側に寄せる。互換 backend を使う場合も、
// base URL を差し替えるだけで同じ `/responses` path を使える。
// Keep `/v1` in base_url. Compatible backends can reuse the same
// `/responses` path by replacing only the base URL.
"https://api.openai.com/v1"
}
+24 -24
View File
@@ -1,8 +1,8 @@
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
//! HTTP クライアントで扱う。
//! `HttpTransport<S: Scheme>` handles all LLM wire schemes with one shared
//! HTTP client.
//!
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲し、backend 固有の
//! HTTP policy は [`TransportPolicy`] で明示的に差し込む。
//! Scheme-specific differences are delegated to [`Scheme`] implementations;
//! backend-specific HTTP policy is explicitly injected via [`TransportPolicy`].
use std::pin::Pin;
use std::sync::Arc;
@@ -28,11 +28,11 @@ 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);
/// 認証設定をリクエスト時に使える形へ解決したランタイム表現。
/// Runtime representation of authentication resolved for use in requests.
///
/// - `None`: 認証ヘッダを送らない(Ollama 等の opt-out)
/// - `ApiKey`: 静的な API key 文字列
/// - `Custom`: リクエスト毎に動的にヘッダを組み立てる
/// - `None`: send no authentication headers (opt-out for Ollama, etc.).
/// - `ApiKey`: static API key string.
/// - `Custom`: build headers dynamically for each request.
#[derive(Debug, Clone)]
pub enum ResolvedAuth {
None,
@@ -41,13 +41,13 @@ pub enum ResolvedAuth {
}
impl ResolvedAuth {
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
/// Check whether resolved authentication matches the requirement at construction.
///
/// - `ResolvedAuth::None` は認証を付けない宣言なので、どの
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
/// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」
/// 宣言なので、scheme が要求する形式によらず受け入れる
/// - `ResolvedAuth::None` declares no authentication, so any `AuthRequirement`
/// is accepted (Ollama reuses the Anthropic scheme with
/// `required_auth = XApiKey`, but works without authentication headers).
/// - `ResolvedAuth::Custom` declares that all header construction is handled
/// externally, so it is accepted regardless of the scheme's required format.
pub fn matches(&self, req: AuthRequirement) -> bool {
match (self, req) {
(Self::None, _) => true,
@@ -177,7 +177,7 @@ fn sse_error_context(status: u16, headers: &serde_json::Value, source: &str) ->
)
}
/// scheme 共通の HTTP 通信層。
/// Shared HTTP transport layer for schemes.
pub struct HttpTransport<S: Scheme> {
http_client: reqwest::Client,
scheme: S,
@@ -189,8 +189,8 @@ pub struct HttpTransport<S: Scheme> {
}
impl<S: Scheme> HttpTransport<S> {
/// 新しい transport を作る。`base_url` は末尾スラッシュの有無を
/// どちらでも受け付ける(内部で正規化)。
/// Create a transport. `base_url` accepts an optional trailing slash
/// (normalized internally).
pub fn new(
scheme: S,
model_id: impl Into<String>,
@@ -217,7 +217,7 @@ impl<S: Scheme> HttpTransport<S> {
self
}
/// カスタム HTTP クライアントを差し込む(テスト等)。
/// Inject a custom HTTP client (e.g. for tests).
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = client;
self
@@ -226,7 +226,7 @@ impl<S: Scheme> HttpTransport<S> {
fn build_url(&self) -> String {
let path = self.scheme.path(&self.model_id);
let url = format!("{}{}", self.base_url, path);
// Gemini のようにクエリパラメータで認証する場合は URL にキーを追記する
// Append the key to the URL for query-parameter authentication, as in Gemini.
if let (AuthRequirement::QueryParam { name }, ResolvedAuth::ApiKey(key)) =
(self.scheme.required_auth(), &self.auth)
{
@@ -262,11 +262,11 @@ impl<S: Scheme> HttpTransport<S> {
headers.insert("x-api-key", val);
}
(_, AuthRequirement::QueryParam { .. }) => {
// クエリパラメータは `build_url` で付与済み
// Query parameters were already added by `build_url`.
}
(ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => {
// scheme が Custom を要求する組合せに ApiKey は流れてこない想定
// (`matches()` で弾かれる)。安全側で何もしない
// ApiKey should never reach a scheme requiring Custom (`matches()`
// rejects it). Do nothing as a conservative fallback.
}
}
@@ -459,7 +459,7 @@ impl<S: Scheme + Clone> Clone for HttpTransport<S> {
}
}
/// エラーレスポンスを `ClientError::Api` に変換する。
/// Convert an error response to `ClientError::Api`.
async fn classify_error_response(resp: reqwest::Response) -> ClientError {
let status = resp.status().as_u16();
let retry_after = resp
@@ -713,7 +713,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
let event_stream = byte_stream.eventsource();
// scheme 固有のパース状態をストリーム単位で保持する
// Keep scheme-specific parsing state for each stream.
let mut state = <S::State as Default>::default();
let stream = event_stream
+20 -21
View File
@@ -151,21 +151,20 @@ pub enum Item {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Reasoning text(reasoning body, `reasoning_text.delta` の累積)
/// Reasoning text (reasoning body, accumulated `reasoning_text.delta`).
text: String,
/// Reasoning summary(OpenAI Responses の `summary_text[]` を格納。
/// 他 scheme は空)
/// Reasoning summary (OpenAI Responses `summary_text[]`; empty for other schemes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
summary: Vec<String>,
/// サーバから返された暗号化済み reasoning blob。ZDR / `store=false`
/// 運用で stateless に再送するときそのまま添える必要がある。
/// Anthropic の `redacted_thinking.data` もここに格納する。
/// Encrypted reasoning blob returned by the server. Must be included unchanged
/// when resending statelessly under ZDR / `store=false`.
/// Also holds 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`。
/// Anthropic extended thinking `signature`, used for tamper detection.
/// Newer Claude models (Opus 4.5+/Sonnet 4.6+) require it when sending
/// `thinking` blocks back within the same logical turn.
/// `None` for other schemes.
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
/// Item status
@@ -624,12 +623,11 @@ pub struct Request {
/// (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 は無視する。
/// Stable conversation key, sent as `prompt_cache_key` (OpenAI Responses).
/// For backends requiring an explicit key, the caller supplies a stable
/// conversation identifier. Unlike `cache_anchor`, this is a namespace key,
/// separate from a `prefix anchor`. Just as some providers ignore
/// `cache_anchor`, providers without `prompt_cache_key` ignore this field.
pub cache_key: Option<String>,
/// Request-local diagnostics sink for transport lifecycle tracing.
#[doc(hidden)]
@@ -725,7 +723,7 @@ impl Request {
/// Set the conversation cache key.
///
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
/// See the [`Request::cache_key`] field documentation for details.
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
self.cache_key = Some(key.into());
self
@@ -790,10 +788,11 @@ pub struct RequestConfig {
pub top_k: Option<u32>,
/// Stop sequences
pub stop_sequences: Vec<String>,
/// Reasoning / extended-thinking 制御(共通型、scheme 側で各社形式に投影)。
/// Shared reasoning / extended-thinking control, projected by each scheme
/// into the provider's format.
///
/// `None` のときは何も送らない。`Some` でも scheme の
/// `ModelCapability::reasoning` が `None` なら無視される。
/// `None` sends nothing. Even `Some` is ignored if the scheme's
/// `ModelCapability::reasoning` is `None`.
#[serde(default)]
pub reasoning: Option<crate::llm_client::capability::ReasoningControl>,
}
@@ -851,7 +850,7 @@ mod parse_tool_arguments_tests {
#[test]
fn literal_null_normalizes_to_object() {
// 既存セッションに残っている "null" が resume 時に復旧できること
// Ensure "null" retained in existing sessions can be recovered on resume.
assert_eq!(parse_tool_arguments("null"), empty_object());
}
+20 -20
View File
@@ -1,9 +1,9 @@
//! `~/.codex/auth.json` の読み書き。
//! Reading and writing `~/.codex/auth.json`.
//!
//! Codex CLI と schema を共有するが、知らないフィールドを失わないよう
//! ファイル全体を `serde_json::Value` で保持し、必要箇所
//! のみアクセスする。書込は `mode 0o600` を再設定(Codex CLI 同様)、
//! ファイルロックは取らない(manager 側で guarded reload)。
//! Shares the schema with Codex CLI, but retains the entire file as
//! `serde_json::Value` and accesses only the required fields to preserve
//! unknown fields. Writes reapply `mode 0o600` (as in Codex CLI),
//! without file locking (the manager performs a guarded reload).
use std::fs::OpenOptions;
use std::io::Write;
@@ -16,7 +16,7 @@ use serde_json::Value;
use super::error::CodexAuthError;
/// auth.json から取り出した使い回す情報のスナップショット。
/// Snapshot of reusable information extracted from auth.json.
#[derive(Debug, Clone)]
pub struct AuthSnapshot {
pub access_token: String,
@@ -24,12 +24,12 @@ pub struct AuthSnapshot {
pub account_id: String,
pub id_token: String,
pub last_refresh: Option<DateTime<Utc>>,
/// 書き戻し時に他のフィールドを失わないため、ファイル全体を保持する。
/// Retain the entire file to preserve other fields when writing back.
pub raw: Value,
}
impl AuthSnapshot {
/// `Value` から必要フィールドを抽出。欠落・型不一致は `MalformedAuthJson`。
/// Extract required fields from `Value`. Missing fields or type mismatches yield `MalformedAuthJson`.
pub fn from_value(raw: Value) -> Result<Self, CodexAuthError> {
let tokens = raw
.get("tokens")
@@ -55,7 +55,7 @@ impl AuthSnapshot {
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.id_token".into()))?
.to_string();
// account_id は tokens.account_id を優先、無ければ id_token JWT 由来
// Prefer tokens.account_id; fall back to the id_token JWT if absent
let account_id = tokens
.get("account_id")
.and_then(Value::as_str)
@@ -84,7 +84,7 @@ impl AuthSnapshot {
}
}
/// auth.json を読む。存在しなければ `NotLoggedIn`。
/// Read auth.json. Return `NotLoggedIn` if it does not exist.
pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
@@ -103,11 +103,11 @@ pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
AuthSnapshot::from_value(raw)
}
/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と
/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。
/// Reload the existing file, update `tokens.{id_token,access_token,refresh_token}`
/// and `last_refresh`, and write it back. Equivalent to Codex CLI's `persist_tokens`.
///
/// 並行する Codex CLI / 別プロセスが先に refresh していた場合の
/// fields を保護するため、書込前に再 load して merge する。
/// Reload and merge before writing to preserve fields if a concurrent
/// Codex CLI / another process has already refreshed.
pub async fn persist_refreshed(
path: &Path,
new_id_token: Option<String>,
@@ -161,8 +161,8 @@ fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
file.flush()
.map_err(|e| CodexAuthError::Io(format!("flush {}: {e}", path.display())))?;
// 既存ファイルが緩いパーミッションだった場合に備えて 0o600 を強制し直す。
// OpenOptions の `mode()` は新規作成時しか効かないため。
// Reapply 0o600 in case the existing file had permissive permissions.
// OpenOptions `mode()` only takes effect when creating a new file.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -204,7 +204,7 @@ mod tests {
assert_eq!(snap.refresh_token, "ref");
assert_eq!(snap.account_id, "acc-1");
assert!(snap.last_refresh.is_some());
// 未知フィールドが raw に保持されている
// Unknown fields are retained in raw
assert_eq!(
snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str),
Some("sk-extra")
@@ -214,7 +214,7 @@ mod tests {
#[tokio::test]
async fn account_id_falls_back_to_jwt_claim() {
let dir = tempfile::tempdir().unwrap();
// tokens.account_id を欠落させ、id_token JWT 内 claim から拾う
// Omit tokens.account_id and obtain it from a claim in the id_token JWT
let id_token = "h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiZnJvbS1qd3QifX0.s";
let path = write_auth_json(
dir.path(),
@@ -249,7 +249,7 @@ mod tests {
.unwrap();
assert_eq!(updated.access_token, "new-acc");
assert_eq!(updated.refresh_token, "new-ref");
// 未知フィールド agent_identity が保たれる
// The unknown field agent_identity is preserved
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());
@@ -264,7 +264,7 @@ mod tests {
dir.path(),
r#"{"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"a","refresh_token":"r","account_id":"a"}}"#,
);
// 既存ファイルを 644 に変えてから persist → 600 に直るか
// Change the existing file to 644, then persist → check that it returns to 600
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
persist_refreshed(&path, None, Some("a2".into()), None)
.await
+6 -6
View File
@@ -1,8 +1,8 @@
//! Codex OAuth サブモジュール内部のエラー型。
//! Internal error types for the Codex OAuth submodule.
//!
//! `LlmClient` 境界に渡す際は `to_client_error` で `ClientError` に
//! 変換する。`Permanent` 系(refresh_token 失効)は `codex login` の
//! 再実行案内付きメッセージにする。
//! Convert to `ClientError` using `to_client_error` at the `LlmClient`
//! boundary. `Permanent` errors (invalidated refresh_token) include
//! a message instructing the user to run `codex login` again.
use std::path::PathBuf;
@@ -25,7 +25,7 @@ pub enum CodexAuthError {
#[error("token refresh failed (transient): {0}")]
RefreshTransient(String),
/// refresh_token が永続的に失効。再ログインが必要。
/// refresh_token is permanently invalidated. Logging in again is required.
#[error("token refresh failed permanently ({reason:?}): {message}")]
RefreshPermanent {
reason: PermanentReason,
@@ -45,7 +45,7 @@ pub enum PermanentReason {
}
impl CodexAuthError {
/// `LlmClient` トランスポート境界向けに変換する。
/// Convert for the `LlmClient` transport boundary.
pub fn to_client_error(self) -> ClientError {
match self {
CodexAuthError::NotLoggedIn(_)
+5 -5
View File
@@ -1,13 +1,13 @@
//! JWT payload の最小限のパース(署名検証なし)。
//! Minimal JWT payload parsing (without signature verification).
//!
//! Codex CLI と同じく、access_token / id_token の payload を base64url
//! デコードして `exp` や ChatGPT 固有 claims を取り出すためだけに使う。
//! As in Codex CLI, used only to base64url-decode access_token / id_token
//! payloads and extract `exp` and ChatGPT-specific claims.
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::Deserialize;
/// `Authorization: Bearer` で送る access_token JWT の `exp` を読む。
/// Read `exp` from the access_token JWT sent in `Authorization: Bearer`.
pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
#[derive(Deserialize)]
struct Claims {
@@ -17,7 +17,7 @@ pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
DateTime::<Utc>::from_timestamp(claims.exp?, 0)
}
/// id_token JWT から ChatGPT 固有 claims を取り出す。
/// Extract ChatGPT-specific claims from the id_token JWT.
pub fn parse_chatgpt_claims(jwt: &str) -> Option<ChatGptClaims> {
#[derive(Deserialize)]
struct IdClaims {
+34 -34
View File
@@ -1,17 +1,17 @@
//! ChatGPT OAuth (`~/.codex/auth.json`) を読んで `Authorization` /
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` ヘッダを組み立てる
//! [`AuthProvider`] 実装。
//! [`AuthProvider`] implementation that reads ChatGPT OAuth credentials
//! (`~/.codex/auth.json`) and builds `Authorization` /
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` headers.
//!
//! 設計:
//! Design:
//!
//! - 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 不在ならエラーで案内
//! - The HTTP transport sees only the [`AuthProvider`] trait; the concrete
//! [`CodexAuthProvider`] lives in this optional module
//! - Read the access_token JWT's `exp` and proactively refresh when `exp` <= `now`
//! (no buffer, as in Codex CLI)
//! - Reload immediately before refreshing and check that account_id matches
//! to avoid confusing concurrent Codex CLI / other-process refreshes (guarded reload)
//! - No file locking; reload and merge differences before writing
//! - Codex Keyring storage is not supported. If auth.json is missing, return an error with guidance
mod auth_json;
mod error;
@@ -32,10 +32,10 @@ use error::CodexAuthError;
pub use error::PermanentReason;
/// Codex CLI の `last_refresh` ベース fallback 期限(Codex CLI 準拠で 8 日)。
/// Codex CLI's `last_refresh`-based fallback interval (8 days, matching Codex CLI).
const TOKEN_REFRESH_INTERVAL_DAYS: i64 = 8;
/// `~/.codex/auth.json` を読んで Codex 互換のヘッダを返す provider。
/// Provider that reads `~/.codex/auth.json` and returns Codex-compatible headers.
pub struct CodexAuthProvider {
auth_path: PathBuf,
refresh_endpoint: String,
@@ -58,7 +58,7 @@ struct State {
}
impl CodexAuthProvider {
/// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。
/// Locate auth.json using `CODEX_HOME`, then `$HOME/.codex` as a fallback.
pub fn from_default_home() -> Result<Self, ClientError> {
let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") {
PathBuf::from(p)
@@ -70,7 +70,7 @@ impl CodexAuthProvider {
Ok(Self::new(codex_home))
}
/// 任意の codex_home から構築する。
/// Construct from a custom codex_home.
pub fn new(codex_home: PathBuf) -> Self {
Self {
auth_path: codex_home.join("auth.json"),
@@ -80,14 +80,14 @@ impl CodexAuthProvider {
}
}
/// テスト用に refresh エンドポイントを差し替える。
/// Override the refresh endpoint for tests.
#[cfg(test)]
fn with_refresh_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.refresh_endpoint = endpoint.into();
self
}
/// テスト用に HTTP クライアントを差し替える。
/// Override the HTTP client for tests.
#[cfg(test)]
fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http = client;
@@ -97,29 +97,29 @@ impl CodexAuthProvider {
async fn ensure_fresh(&self) -> Result<AuthSnapshot, CodexAuthError> {
let mut state = self.state.lock().await;
// 1. 常にディスクから最新を読む(他プロセスの更新を反映)
// 1. Always read the latest from disk (reflect updates by other processes)
let snap = auth_json::load(&self.auth_path).await?;
// 2. stale でなければそのまま返す
// 2. Return as-is if not stale
if !is_stale(&snap) {
state.cached = Some(snap.clone());
return Ok(snap);
}
// 3. Refresh 直前に再 load し account_id 一致を確認(guarded reload)。
// 一致しなければ他プロセスが先に更新済 → 自分は refresh しない
// 3. Reload immediately before refreshing and check that account_id matches (guarded reload).
// If it differs, another process has already updated it → skip refreshing
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 はしない
// The account has changed → use the new auth without refreshing
state.cached = Some(pre_refresh.clone());
return Ok(pre_refresh);
}
// 4. Refresh 実行
// 4. Perform the refresh
let refreshed = refresh::request_refresh(
&self.http,
&self.refresh_endpoint,
@@ -127,7 +127,7 @@ impl CodexAuthProvider {
)
.await?;
// 5. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施)
// 5. Write back (persist_refreshed reloads and merges before writing)
let new_snap = auth_json::persist_refreshed(
&self.auth_path,
refreshed.id_token,
@@ -152,9 +152,9 @@ impl CodexAuthProvider {
.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 に到達できない。
// Cloudflare WAF identifies clients accessing the compatible backend by `originator` /
// `User-Agent`. Without the fixed values sent by Codex CLI,
// it returns an HTML challenge (403), preventing access to SSE.
out.push((
HeaderName::from_static("originator"),
HeaderValue::from_static("codex_cli_rs"),
@@ -164,7 +164,7 @@ impl CodexAuthProvider {
HeaderValue::from_static("codex_cli_rs/0.60.0"),
));
// FedRAMP 組織は id_token JWT 内の claim で判定
// Identify FedRAMP organizations using a claim in the id_token JWT
if jwt::parse_chatgpt_claims(&snap.id_token)
.map(|c| c.is_fedramp)
.unwrap_or(false)
@@ -190,8 +190,8 @@ impl AuthProvider for CodexAuthProvider {
}
}
/// `access_token` の JWT `exp` を見て、期限切れなら true。
/// `exp` が読めない場合は `last_refresh + 8 日` で判定(Codex CLI と同じ)。
/// Return true if the `access_token` JWT's `exp` indicates expiration.
/// If `exp` cannot be read, use `last_refresh + 8 days` (as in Codex CLI).
fn is_stale(snap: &AuthSnapshot) -> bool {
if let Some(exp) = jwt::parse_exp(&snap.access_token) {
return exp <= Utc::now();
@@ -241,7 +241,7 @@ mod tests {
#[tokio::test]
async fn returns_headers_for_fresh_token() {
let dir = tempfile::tempdir().unwrap();
// 期限を遠い未来に
// Set expiration far in the future
write_auth(dir.path(), Utc::now().timestamp() + 3600, false, "rt");
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
@@ -285,7 +285,7 @@ mod tests {
"old-refresh",
);
// refresh エンドポイントを mock。新しい JWT (将来 exp) を返す
// Mock the refresh endpoint to return a new JWT (future exp)
let server = MockServer::start().await;
let new_access = make_jwt(json!({ "exp": Utc::now().timestamp() + 3600 }));
let new_refresh = "new-refresh-token";
@@ -310,7 +310,7 @@ mod tests {
.unwrap();
assert_eq!(auth.1.to_str().unwrap(), format!("Bearer {new_access}"));
// ファイルに新しい refresh_token が書き戻されている
// The new refresh_token has been written back to the file
let on_disk: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(
+6 -6
View File
@@ -1,7 +1,7 @@
//! ChatGPT OAuth トークンの refresh HTTP 呼出。
//! HTTP calls to refresh ChatGPT OAuth tokens.
//!
//! Codex CLI と同じ `POST https://auth.openai.com/oauth/token` 形式。
//! 401 + `error.code` で永続失敗を分類する。
//! Uses the same `POST https://auth.openai.com/oauth/token` format as Codex CLI.
//! Classifies permanent failures using 401 + `error.code`.
use serde::{Deserialize, Serialize};
use std::time::Duration;
@@ -29,10 +29,10 @@ pub struct RefreshResponse {
pub refresh_token: Option<String>,
}
/// refresh_token を使って新しいトークン群を取得する。
/// Obtain new tokens using refresh_token.
///
/// 永続失敗(401 + `refresh_token_(expired|reused|invalidated)`)は
/// `RefreshPermanent`、それ以外は `RefreshTransient`。
/// Permanent failures (401 + `refresh_token_(expired|reused|invalidated)`) return
/// `RefreshPermanent`; all others return `RefreshTransient`.
pub async fn request_refresh(
client: &reqwest::Client,
endpoint: &str,
+27 -30
View File
@@ -1,19 +1,17 @@
//! Prune — context projection for old tool-result content.
//! Request-only omission of details from old tool results.
//!
//! LLM 送信時のコンテキストから古い [`Item::ToolResult`] の `content` を
//! 省略して、コンテキスト窓のトークンを回収する。`summary` は残すので
//! 「何が起きたか」の痕跡は保たれる。
//! [`prunable_indices`] selects candidates while protecting a token budget at the
//! history tail; [`project`] removes detailed text and attachments. Summaries remain
//! visible to the model. Projection operates on a request-history clone, leaving
//! stored history and annotations unchanged.
//!
//! # 設計方針
//! This module does not own token measurements. The host supplies prefix and savings
//! estimates; unavailable estimates or invalid boundaries produce no candidates.
//! The Engine also checks savings to avoid invalidating the cache for little benefit.
//!
//! Prune は **コンテキスト射影** であり、history の変換ではない。
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
//! 射影の適用は LLM に送る一時コンテキストに対してだけ行う。
//! Engine の永続履歴は決して変更されない。
//!
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
//! 所有しない。prefix ごとの token 推定値と savings 推定は上位層から
//! callback で注入される。
//! Compaction, which stores a summary as a new segment, is a separate responsibility.
//! Omitting already-consumed images likewise affects only the request projection,
//! never the stored results.
use serde::{Deserialize, Serialize};
@@ -36,27 +34,26 @@ pub type TokenEstimator = Box<dyn Fn(&[Item]) -> Vec<TokenEstimate> + Send + Syn
/// 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 を返す必要がある。
/// Estimate the savings from removing details, not from dropping entire items.
/// Items and their summaries remain, so this callback must match the actual projection.
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 を持つ。
/// The Engine evaluates pruning once per LLM request and reports this value if
/// an observer is registered. It includes the decision and its inputs: candidate
/// count, estimated savings, and the start index of the protected suffix.
#[derive(Debug, Clone)]
pub struct PruneEvaluation {
/// `prunable_indices` の長さ。`Skipped::NoCandidates` の時は 0。
/// Length of `prunable_indices`; zero for `SkippedNoCandidates`.
pub candidate_count: usize,
/// 推定された savings (tokens)。`NoCandidates` の時は 0。
/// Estimated token savings; zero when there are no candidates.
pub estimated_savings: u64,
/// Token budget で保護される suffix の先頭 item index。
/// usage 推定が `NoData` で境界が決まらない場合は `None`。
/// First item in the suffix protected by the token budget.
/// `None` when `NoData` estimates prevent determining the boundary.
pub protected_start_index: Option<usize>,
/// 判定結果。
/// Evaluation outcome.
pub decision: PruneDecision,
}
@@ -64,13 +61,13 @@ pub struct PruneEvaluation {
/// "fire vs skip" decision tree the Engine walks before each LLM request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PruneDecision {
/// `prunable_indices` が空 → 何もしない。
/// No projection: `prunable_indices` is empty.
SkippedNoCandidates,
/// 候補はあったが推定 savings が `min_savings` 未満 → 何もしない。
/// No projection: candidates exist, but estimated savings are below `min_savings`.
SkippedBelowMinSavings,
/// 候補があり savings >= min_savings → projection を適用した。
/// `pruned_count` は `project()` が実際に書き換えた item 数
/// (既に content=None だった候補は 0 計上)。
/// Applied projection because candidates exist and savings meet `min_savings`.
/// `pruned_count` counts items actually changed by `project()`;
/// candidates that already lack details contribute zero.
Fired { pruned_count: usize },
}
@@ -513,7 +510,7 @@ mod tests {
let estimates = uniform_estimates(&items, 10);
let candidates = prunable_indices(&items, 20, &estimates);
assert_eq!(project(&mut items, &candidates), 1);
// 2 周目: 候補は一度の prunable_indices 結果を使い回しても 0 件。
// Second pass: reusing the same prunable_indices result changes zero items.
assert_eq!(project(&mut items, &candidates), 0);
}
+14 -38
View File
@@ -1,58 +1,34 @@
//! Engine State
//! Type states that restrict changes to Engine configuration.
//!
//! State marker types for cache protection using the Type-state pattern.
//! Engine has state transitions from `Mutable` → `Locked`.
//! Transitioning from [`Mutable`] to [`Locked`] fixes the configuration and history
//! prefix length, restricting changes to system prompts and tool definitions during
//! execution. Changes require an explicit unlock because altering the prefix may
//! prevent prompt-cache reuse. History itself belongs to the caller: these states
//! neither prevent external history mutations nor guarantee cache hits.
/// Marker trait representing Engine state
/// Sealed trait for Engine type states.
///
/// This trait is sealed and cannot be implemented externally.
/// External implementations are prohibited so the crate controls the separation
/// between configuration and execution APIs.
pub trait EngineState: private::Sealed + Send + Sync + 'static {}
mod private {
pub trait Sealed {}
}
/// Mutable state (editable)
/// State in which configuration can be edited.
///
/// 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(&history)`.
///
/// # 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();
/// ```
/// Transitions to [`Locked`] through `Engine::lock` or the first `run`.
#[derive(Debug, Clone, Copy, Default)]
pub struct Mutable;
impl private::Sealed for Mutable {}
impl EngineState for Mutable {}
/// Cache locked state (cache protected)
/// State with fixed configuration and history prefix length.
///
/// 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.
/// `Engine::unlock` returns to [`Mutable`] and permits configuration changes,
/// but requests made after those changes may no longer reuse the previous cache.
#[derive(Debug, Clone, Copy, Default)]
pub struct Locked;
+2 -2
View File
@@ -1,5 +1,5 @@
//! Timeline層のイベント型
//! Event types for the Timeline layer.
//!
//! llm_client層のイベント型をそのまま使用する。
//! Uses the event types from the llm_client layer directly.
pub use crate::llm_client::event::*;
+7 -12
View File
@@ -1,14 +1,9 @@
//! Timeline層
//! Dispatch normalized LLM events and collect blocks.
//!
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
//!
//! # 主要コンポーネント
//!
//! - [`Timeline`] - イベントストリームの管理とディスパッチ
//! - [`Handler`] - イベントを処理するトレイト
//! - [`TextBlockCollector`] - テキストブロックを収集するHandler
//! - [`ThinkingBlockCollector`] - reasoning material を Thinking block から収集するHandler
//! - [`ToolCallCollector`] - ツール呼び出しを収集するHandler
//! [`Timeline`] dispatches common events to handlers without parsing provider protocols.
//! Block assembly and display callbacks are separate from turn decisions. The Engine
//! uses collected responses to update history and execute tools. Provisional streaming
//! output is distinct from committed history.
pub mod event;
mod text_block_collector;
@@ -16,7 +11,7 @@ mod thinking_block_collector;
mod timeline;
mod tool_call_collector;
// 公開API
// Public API
pub use event::*;
pub use text_block_collector::TextBlockCollector;
pub use thinking_block_collector::ThinkingBlockCollector;
@@ -24,7 +19,7 @@ pub use timeline::Timeline;
pub(crate) use tool_call_collector::CollectedToolCall;
pub use tool_call_collector::ToolCallCollector;
// 型定義からのre-export
// Re-exports from type definitions
pub use crate::handler::{
// Meta Kinds
ErrorKind,
@@ -1,53 +1,51 @@
//! TextBlockCollector - テキストブロック収集用ハンドラ
//! TextBlockCollector - a handler for collecting text blocks.
//!
//! TimelineのTextBlockHandler として登録され、
//! ストリーム中のテキストブロックを収集する。
//! Registered as a TextBlockHandler on Timeline to collect streaming text blocks.
use crate::handler::{Handler, TextBlockEvent, TextBlockKind};
use std::sync::{Arc, Mutex};
/// TextBlockから収集したテキスト情報を保持
/// Holds text collected from TextBlock events.
#[derive(Debug, Default)]
pub struct TextCollectorState {
/// 蓄積中のテキスト
/// Text being accumulated.
buffer: String,
}
/// TextBlockCollector - テキストブロックハンドラ
/// TextBlockCollector - a text block handler.
///
/// Timelineに登録してTextBlockイベントを受信し、
/// 完了したテキストブロックを収集する。
/// Register with Timeline to receive TextBlock events and collect completed text blocks.
#[derive(Clone)]
pub struct TextBlockCollector {
/// 収集されたテキストブロック
/// Collected text blocks.
collected: Arc<Mutex<Vec<String>>>,
}
impl TextBlockCollector {
/// 新しいTextBlockCollectorを作成
/// Create a new TextBlockCollector.
pub fn new() -> Self {
Self {
collected: Arc::new(Mutex::new(Vec::new())),
}
}
/// 収集されたテキストを取得してクリア
/// Take the collected text and clear the collection.
pub fn take_collected(&self) -> Vec<String> {
let mut guard = self.collected.lock().unwrap();
std::mem::take(&mut *guard)
}
/// 収集されたテキストの参照を取得
/// Access the collected text.
pub fn collected(&self) -> Vec<String> {
self.collected.lock().unwrap().clone()
}
/// 収集されたテキストがあるかどうか
/// Whether any text has been collected.
pub fn has_content(&self) -> bool {
!self.collected.lock().unwrap().is_empty()
}
/// 収集をクリア
/// Clear the collection.
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
@@ -71,7 +69,7 @@ impl Handler<TextBlockKind> for TextBlockCollector {
scope.buffer.push_str(text);
}
TextBlockEvent::Stop(_) => {
// ブロック完了時にテキストを確定
// Finalize the text when the block completes.
if !scope.buffer.is_empty() {
let text = std::mem::take(&mut scope.buffer);
self.collected.lock().unwrap().push(text);
@@ -87,38 +85,38 @@ mod tests {
use crate::timeline::Timeline;
use crate::timeline::event::Event;
/// TextBlockCollectorが単一のテキストブロックを正しく収集することを確認
/// Verify that TextBlockCollector correctly collects a single text block.
#[test]
fn test_collect_single_text_block() {
let collector = TextBlockCollector::new();
let mut timeline = Timeline::new();
timeline.on_text_block(collector.clone());
// テキストブロックのイベントシーケンスをディスパッチ
// Dispatch a sequence of text block events.
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));
// 収集されたテキストを確認
// Check the collected text.
let texts = collector.take_collected();
assert_eq!(texts.len(), 1);
assert_eq!(texts[0], "Hello, World!");
}
/// TextBlockCollectorが複数のテキストブロックを正しく収集することを確認
/// Verify that TextBlockCollector correctly collects multiple text blocks.
#[test]
fn test_collect_multiple_text_blocks() {
let collector = TextBlockCollector::new();
let mut timeline = Timeline::new();
timeline.on_text_block(collector.clone());
// 1つ目のテキストブロック
// First text block
timeline.dispatch(&Event::text_block_start(0));
timeline.dispatch(&Event::text_delta(0, "First"));
timeline.dispatch(&Event::text_block_stop(0, None));
// 2つ目のテキストブロック
// Second text block
timeline.dispatch(&Event::text_block_start(1));
timeline.dispatch(&Event::text_delta(1, "Second"));
timeline.dispatch(&Event::text_block_stop(1, None));
@@ -20,13 +20,13 @@ impl ThinkingBlockCollector {
Self::default()
}
/// 収集済み item を取り出してクリア
/// Take the collected items and clear the collection.
pub fn take_collected(&self) -> Vec<ReasoningBlockData> {
let mut guard = self.collected.lock().unwrap();
std::mem::take(&mut *guard)
}
/// 収集をクリア
/// Clear the collection.
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
@@ -107,7 +107,7 @@ mod tests {
assert_eq!(items[0].signature.as_deref(), Some("sig1"));
assert_eq!(items[1].text.as_deref(), Some("second"));
// take は drain なので 2 度目は空
// Taking drains the collection, so the second call is empty.
assert!(collector.take_collected().is_empty());
}
+68 -68
View File
@@ -1,7 +1,7 @@
//! Timeline層
//! Timeline layer.
//!
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
//! 通常はEngine経由で使用しますが、直接使用することも可能です。
//! Receives an LLM event stream and dispatches it to registered handlers.
//! Usually used through the Engine, but can also be used directly.
use std::{collections::HashMap, marker::PhantomData};
@@ -12,11 +12,11 @@ use crate::handler::*;
// Helpers
// =============================================================================
/// 1リクエスト内で受信した複数 UsageEvent をマージする。
/// 各フィールドについて新しい値が `Some` ならそれで上書き。
/// プロバイダによっては input/cache 系を最初の event だけに載せ、
/// output_tokens を後続 event で更新するため、最後の値だけを取るのではなく
/// フィールド単位で latest-non-None を取る。
/// Merge multiple UsageEvents received within one request.
/// Overwrite each field when the new value is `Some`.
/// Some providers send input/cache fields only in the first event and update
/// output_tokens in later events, so use the latest non-None value per field
/// rather than taking only the last event.
fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
if new.input_tokens.is_some() {
acc.input_tokens = new.input_tokens;
@@ -39,19 +39,19 @@ fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
// Type-erased Handler
// =============================================================================
/// 型消去された`Handler` trait
/// Type-erased `Handler` trait.
///
/// 各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要です。
/// 通常は直接使用せず、`Timeline::on_text_block()`などのメソッド経由で
/// 自動的にラップされます。
/// Each handler has its own Scope type, so Timeline needs type erasure to store them.
/// Usually wrapped automatically by methods such as `Timeline::on_text_block()`
/// rather than used directly.
pub trait ErasedHandler<K: Kind>: Send + Sync {
/// イベントをディスパッチ
/// Dispatch an event.
fn dispatch(&mut self, event: &K::Event);
/// スコープを開始
/// Start a scope.
fn start_scope(&mut self);
}
/// `Handler<K>`を`ErasedHandler<K>`として扱うためのラッパー
/// Wrapper that exposes `Handler<K>` as `ErasedHandler<K>`.
pub struct HandlerWrapper<H, K>
where
H: Handler<K>,
@@ -59,7 +59,7 @@ where
{
handler: H,
scope: Option<H::Scope>,
// fn() -> K は常にSend+Syncなので、Kの制約に関係なくSendを満たせる
// fn() -> K is always Send+Sync, so Send does not depend on K's bounds.
_kind: PhantomData<fn() -> K>,
}
@@ -98,7 +98,7 @@ where
// Block Handler Registry
// =============================================================================
/// ブロックハンドラーの型消去trait
/// Type-erased trait for block handlers.
trait ErasedBlockHandler: Send + Sync {
fn dispatch_start(&mut self, start: &BlockStart);
fn dispatch_delta(&mut self, delta: &BlockDelta);
@@ -106,11 +106,11 @@ trait ErasedBlockHandler: Send + Sync {
fn dispatch_abort(&mut self, abort: &BlockAbort);
fn start_scope(&mut self);
fn end_scope(&mut self);
/// スコープがアクティブかどうか
/// Whether a scope is active.
fn has_scope(&self) -> bool;
}
/// TextBlockKind用のラッパー
/// Wrapper for TextBlockKind.
struct TextBlockHandlerWrapper<H>
where
H: Handler<TextBlockKind>,
@@ -167,7 +167,7 @@ where
}
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
// TextBlockはabortを特別扱いしない(スコープ終了のみ)
// TextBlock needs no special abort handling (only scope cleanup).
}
fn start_scope(&mut self) {
@@ -183,7 +183,7 @@ where
}
}
/// ThinkingBlockKind用のラッパー
/// Wrapper for ThinkingBlockKind.
struct ThinkingBlockHandlerWrapper<H>
where
H: Handler<ThinkingBlockKind>,
@@ -250,7 +250,7 @@ where
}
}
/// ToolUseBlockKind用のラッパー
/// Wrapper for ToolUseBlockKind.
struct ToolUseBlockHandlerWrapper<H>
where
H: Handler<ToolUseBlockKind>,
@@ -335,10 +335,10 @@ where
// Timeline
// =============================================================================
/// イベントストリームの管理とハンドラへのディスパッチ
/// Manages an event stream and dispatches events to handlers.
///
/// LLMからのイベントを受信し、登録されたハンドラに振り分けます。
/// ブロック系イベントはスコープ管理付きで処理されます。
/// Receives events from the LLM and routes them to registered handlers.
/// Block events are handled with scope management.
///
/// # Examples
///
@@ -359,30 +359,30 @@ where
/// timeline.on_text_block(MyHandler);
/// ```
///
/// # サポートするイベント種別
/// # Supported event kinds
///
/// - **メタ系**: Usage, Ping, Status, Error
/// - **ブロック系**: TextBlock, ThinkingBlock, ToolUseBlock
/// - **Meta**: Usage, Ping, Status, Error
/// - **Block**: TextBlock, ThinkingBlock, ToolUseBlock
pub struct Timeline {
// Meta系ハンドラー
// Meta handlers
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ごとにグループ化)
// Block handlers (grouped by BlockType)
text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
// 現在アクティブなブロック。provider は index ごとに複数 scope を
// interleave できるため、単一の current block ではなく全 scope を追跡する。
// Currently active blocks. Providers can interleave multiple scopes by index,
// so track all scopes rather than a single current block.
active_blocks: HashMap<(BlockType, usize), ()>,
current_block: Option<BlockType>,
// 1リクエスト内で受信した Usage event の集約バッファ。
// Anthropic は message_start と message_delta、Gemini は各チャンクと、
// 多くのプロバイダが複数 Usage を発行するため、リクエスト境界で
// 1度だけ発火するためにここでマージする。flush_usage() で発火する。
// Aggregation buffer for Usage events received within one request.
// Many providers emit multiple Usage events: Anthropic in message_start and
// message_delta, Gemini in each chunk. Merge them here to dispatch only once
// at the request boundary via flush_usage().
pending_usage: Option<UsageEvent>,
}
@@ -412,20 +412,20 @@ impl Timeline {
// Handler Registration
// =========================================================================
/// UsageKind用のHandlerを登録
/// Register a Handler for UsageKind.
pub fn on_usage<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<UsageKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
// Meta系はデフォルトでスコープを開始しておく
// Meta handlers start with a scope by default.
let mut wrapper = HandlerWrapper::new(handler);
wrapper.start_scope();
self.usage_handlers.push(Box::new(wrapper));
self
}
/// PingKind用のHandlerを登録
/// Register a Handler for PingKind.
pub fn on_ping<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<PingKind> + Send + Sync + 'static,
@@ -437,7 +437,7 @@ impl Timeline {
self
}
/// StatusKind用のHandlerを登録
/// Register a Handler for StatusKind.
pub fn on_status<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<StatusKind> + Send + Sync + 'static,
@@ -449,7 +449,7 @@ impl Timeline {
self
}
/// ErrorKind用のHandlerを登録
/// Register a Handler for ErrorKind.
pub fn on_error<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ErrorKind> + Send + Sync + 'static,
@@ -461,7 +461,7 @@ impl Timeline {
self
}
/// TextBlockKind用のHandlerを登録
/// Register a Handler for TextBlockKind.
pub fn on_text_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<TextBlockKind> + Send + Sync + 'static,
@@ -472,7 +472,7 @@ impl Timeline {
self
}
/// ThinkingBlockKind用のHandlerを登録
/// Register a Handler for ThinkingBlockKind.
pub fn on_thinking_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ThinkingBlockKind> + Send + Sync + 'static,
@@ -483,7 +483,7 @@ impl Timeline {
self
}
/// ToolUseBlockKind用のHandlerを登録
/// Register a Handler for ToolUseBlockKind.
pub fn on_tool_use_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ToolUseBlockKind> + Send + Sync + 'static,
@@ -498,10 +498,10 @@ impl Timeline {
// Event Dispatch
// =========================================================================
/// メインのディスパッチエントリポイント
/// Main dispatch entry point.
pub fn dispatch(&mut self, event: &Event) {
match event {
// Meta系: 即時ディスパッチ(登録順)
// Buffer usage until flush; dispatch other handled meta events immediately.
Event::Usage(u) => self.dispatch_usage(u),
Event::Ping(p) => self.dispatch_ping(p),
Event::Status(s) => self.dispatch_status(s),
@@ -509,7 +509,7 @@ impl Timeline {
// Observability-only event: stream trace records it before timeline dispatch.
Event::UnhandledSse(_) => {}
// Block系: スコープ管理しながらディスパッチ
// Block events: dispatch with scope management.
Event::BlockStart(s) => self.handle_block_start(s),
Event::BlockDelta(d) => self.handle_block_delta(d),
Event::BlockStop(s) => self.handle_block_stop(s),
@@ -517,9 +517,9 @@ impl Timeline {
}
}
/// Usage event を即時には dispatch せず、pending_usage にマージする。
/// 1リクエスト内で複数の Usage event が来ても、ハンドラには 1 度だけ
/// 最終値を渡したいため。flush_usage() で発火する。
/// Merge Usage events into pending_usage instead of dispatching immediately.
/// Even if a request emits multiple Usage events, handlers receive the final
/// values only once, when flush_usage() dispatches them.
fn dispatch_usage(&mut self, event: &UsageEvent) {
match &mut self.pending_usage {
Some(acc) => merge_usage(acc, event),
@@ -527,9 +527,9 @@ impl Timeline {
}
}
/// pending_usage を usage_handlers に発火し、バッファをクリアする。
/// 1リクエスト分のストリーム終了時に1回だけ呼ぶ想定。
/// pending_usage が空ならば何もしない。
/// Dispatch pending_usage to usage_handlers and clear the buffer.
/// Intended to be called once at the end of a request's stream.
/// Does nothing if pending_usage is empty.
pub fn flush_usage(&mut self) {
if let Some(event) = self.pending_usage.take() {
for handler in &mut self.usage_handlers {
@@ -571,8 +571,8 @@ impl Timeline {
fn handle_block_delta(&mut self, delta: &BlockDelta) {
let block_type = delta.delta.block_type();
// OpenAIなどのプロバイダはBlockStartを送らない場合があるため、
// Deltaが来たときにその index のスコープがなければ暗黙的に開始する
// Providers such as OpenAI may omit BlockStart, so implicitly start a scope
// for this index when a Delta arrives without one.
self.active_blocks
.entry((block_type, delta.index))
.or_insert(());
@@ -580,7 +580,7 @@ impl Timeline {
let handlers = self.get_block_handlers_mut(block_type);
for handler in handlers {
// スコープがなければ暗黙的に開始
// Implicitly start a scope if none exists.
if !handler.has_scope() {
handler.start_scope();
}
@@ -624,19 +624,19 @@ impl Timeline {
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として扱う
BlockType::ToolResult => &mut self.text_block_handlers, // Treat ToolResult as Text.
}
}
/// 現在アクティブなブロックタイプを取得
/// Get the currently active block type.
pub fn current_block(&self) -> Option<BlockType> {
self.current_block
}
/// 現在アクティブな全ブロックを中断する
/// Abort all currently active blocks.
///
/// キャンセルやエラー時に呼び出し、index ごとに進行中の全ブロックへ
/// BlockAbortイベントを発火してスコープをクリーンアップする。
/// Call on cancellation or error to emit BlockAbort events for all in-progress
/// blocks by index and clean up their scopes.
pub fn abort_current_block(&mut self) {
let active = self.active_blocks.keys().copied().collect::<Vec<_>>();
for (block_type, index) in active {
@@ -739,7 +739,7 @@ mod tests {
#[test]
fn test_meta_event_dispatch() {
// シンプルなテスト用構造体
// Simple test struct
struct TestUsageHandler {
calls: Arc<Mutex<Vec<UsageEvent>>>,
}
@@ -760,10 +760,10 @@ mod tests {
timeline.on_usage(handler);
timeline.dispatch(&Event::usage(100, 50));
// pending_usage に積まれているだけなのでまだ未発火
// Only buffered in pending_usage; not yet dispatched.
assert_eq!(calls.lock().unwrap().len(), 0);
// flush で 1 度だけ発火
// Flush dispatches once.
timeline.flush_usage();
let recorded = calls.lock().unwrap();
assert_eq!(recorded.len(), 1);
@@ -788,7 +788,7 @@ mod tests {
calls: calls.clone(),
});
// Anthropic 風: message_start で input + 暫定 output
// Anthropic-style: input + provisional output in message_start.
timeline.dispatch(&Event::Usage(UsageEvent {
input_tokens: Some(409),
output_tokens: Some(1),
@@ -796,7 +796,7 @@ mod tests {
cache_read_input_tokens: Some(0),
cache_creation_input_tokens: Some(0),
}));
// message_delta で最終 output
// Final output in message_delta.
timeline.dispatch(&Event::Usage(UsageEvent {
input_tokens: Some(409),
output_tokens: Some(71),
@@ -805,7 +805,7 @@ mod tests {
cache_creation_input_tokens: Some(0),
}));
// 未 flush の段階では発火しない
// No dispatch before flushing.
assert_eq!(calls.lock().unwrap().len(), 0);
timeline.flush_usage();
@@ -814,7 +814,7 @@ mod tests {
assert_eq!(recorded[0].input_tokens, Some(409));
assert_eq!(recorded[0].output_tokens, Some(71));
// flush 後にもう一度 flush しても何も起きない
// Flushing again after a flush does nothing.
drop(recorded);
timeline.flush_usage();
assert_eq!(calls.lock().unwrap().len(), 1);
+20 -22
View File
@@ -1,7 +1,6 @@
//! ToolCallCollector - ツール呼び出し収集用ハンドラ
//! ToolCallCollector - a handler for collecting tool calls.
//!
//! TimelineのToolUseBlockHandler として登録され、
//! ストリーム中のToolUseブロックを収集する。
//! Registered as a ToolUseBlockHandler on Timeline to collect streaming ToolUse blocks.
use crate::{
handler::{Handler, ToolUseBlockEvent, ToolUseBlockKind},
@@ -9,17 +8,17 @@ use crate::{
};
use std::sync::{Arc, Mutex};
/// ToolUseブロックから収集したツール呼び出し情報を保持
/// Holds tool call information collected from ToolUse blocks.
///
/// ToolCallCollectorのHandler実装で使用するスコープ型
/// Scope type for ToolCallCollector's Handler implementation.
#[derive(Debug, Default)]
pub struct CollectorState {
/// 現在のツール呼び出し情報 (ブロック進行中)
/// Current tool call information (while the block is in progress).
current_id: Option<String>,
current_name: Option<String>,
/// Zero-based order among tool calls in this assistant response.
call_index: Option<usize>,
/// 蓄積中のJSON入力
/// JSON input being accumulated.
input_json_buffer: String,
}
@@ -30,19 +29,18 @@ pub(crate) struct CollectedToolCall {
pub call: ToolCall,
}
/// ToolCallCollector - ToolUseブロックハンドラ
/// ToolCallCollector - a ToolUse block handler.
///
/// Timelineに登録してToolUseブロックイベントを受信し、
/// 完了したToolCallを収集する。
/// Register with Timeline to receive ToolUse block events and collect completed ToolCalls.
#[derive(Clone)]
pub struct ToolCallCollector {
/// 収集されたToolCall
/// Collected ToolCalls.
collected: Arc<Mutex<Vec<CollectedToolCall>>>,
next_call_index: Arc<Mutex<usize>>,
}
impl ToolCallCollector {
/// 新しいToolCallCollectorを作成
/// Create a new ToolCallCollector.
pub fn new() -> Self {
Self {
collected: Arc::new(Mutex::new(Vec::new())),
@@ -50,7 +48,7 @@ impl ToolCallCollector {
}
}
/// 収集されたToolCallを取得してクリア
/// Take the collected ToolCalls and clear the collection.
pub fn take_collected(&self) -> Vec<ToolCall> {
self.take_collected_with_index()
.into_iter()
@@ -64,7 +62,7 @@ impl ToolCallCollector {
std::mem::take(&mut *guard)
}
/// 収集されたToolCallの参照を取得
/// Access the collected ToolCalls.
pub fn collected(&self) -> Vec<ToolCall> {
self.collected
.lock()
@@ -80,12 +78,12 @@ impl ToolCallCollector {
*self.next_call_index.lock().unwrap() = 0;
}
/// 収集されたToolCallがあるかどうか
/// Whether any ToolCalls have been collected.
pub fn has_pending_calls(&self) -> bool {
!self.collected.lock().unwrap().is_empty()
}
/// 収集をクリア
/// Clear the collection.
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
@@ -116,7 +114,7 @@ impl Handler<ToolUseBlockKind> for ToolCallCollector {
scope.input_json_buffer.push_str(delta);
}
ToolUseBlockEvent::Stop(_stop) => {
// ブロック完了時にToolCallを確定
// Finalize the ToolCall when the block completes.
if let (Some(id), Some(name)) = (scope.current_id.take(), scope.current_name.take())
{
// A Stop event proves the provider finished the block, but
@@ -161,13 +159,13 @@ mod tests {
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
// ToolUseブロックのイベントシーケンスをディスパッチ
// Dispatch a sequence of ToolUse block events.
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を確認
// Check the collected ToolCalls.
let calls = collector.take_collected();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "tool_123");
@@ -177,7 +175,7 @@ mod tests {
#[test]
fn test_collect_empty_buffer_returns_object() {
// 引数なしツール呼び出し: input_json_delta が一度も来ないケース
// A tool call without arguments: no input_json_delta arrives.
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
@@ -239,12 +237,12 @@ mod tests {
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
// 1つ目のToolCall
// First 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
// Second 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));
+28 -29
View File
@@ -1,41 +1,40 @@
//! Usage 履歴ベースのトークン会計(汎用部分)。
//! Token estimates based on provider measurements and history growth.
//!
//! `UsageRecord` の列(プロバイダ実測値)と現在の history から、
//! 任意の history index 時点のプロンプト全長トークン数を pure に計算する。
//! Uses the full input token count from [`UsageRecord`], interpolating between
//! measured boundaries by byte count and extrapolating beyond the latest boundary
//! from text bytes. Input totals include cache reads rather than inventing a
//! system/history breakdown.
//!
//! # 方針
//!
//! - ローカルトークナイザは持たない。実測値があればそれを採用し、
//! measurement 間はバイト数で按分、最新 measurement より先は byte/4
//! fallback で外挿する
//! - 推定の出どころは [`EstimateSource`] で呼び出し側に明示する。
//! 課金判断には使えないが、compact / prune / memory extract trigger 等の
//! 閾値判定には十分な精度
//! - `records` は `history_len` 昇順を仮定する(呼び出し側がそのように積む)
//! [`EstimateSource`] distinguishes measurements from estimates. Estimates inform
//! threshold decisions; they neither determine billing nor guarantee that a request
//! fits the provider's context limit. Attachment base64 is a storage representation,
//! not text input, and is excluded from byte counts. Provider measurements for
//! requests that actually sent images remain valid. The caller supplies records in
//! ascending `history_len` order.
use crate::{Item, UsageRecord};
/// 推定の出どころ。
/// Source of a token estimate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EstimateSource {
/// measurement の境界にちょうど一致(実測値そのもの)
/// Exact match with a measurement boundary; uses the measured value.
Measured,
/// 連続する 2 つの measurement の間をバイト按分で計算
/// Interpolated between consecutive measurements in proportion to byte count.
Interpolated,
/// 最後の measurement より新しい区間を byte/4 fallback で外挿
/// Extrapolated beyond the latest measurement using the bytes/4 fallback.
Extrapolated,
/// measurement が 1 件も無く、バイト数のみのフォールバック
/// Byte-count fallback with no measurements available.
NoData,
}
/// トークン数の推定値。
/// Estimated token count.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TokenEstimate {
pub tokens: u64,
pub source: EstimateSource,
}
/// `items[..i]` までの累積バイト数(`prefix[i]`)を返す。長さは `items.len()+1`。
/// Returns cumulative bytes for `items[..i]` in `prefix[i]`, with length `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;
@@ -47,7 +46,7 @@ pub fn prefix_bytes(items: &[Item]) -> Vec<u64> {
prefix
}
/// 1 Item の text-shaped size。JSON シリアライズ長を使う粗い近似。
/// Approximate text size of one Item, using its JSON serialization length.
///
/// Binary attachments are deliberately projected out. Their durable base64 encoding is a
/// storage representation, not provider text, so charging it at the text `bytes / 4` fallback
@@ -63,11 +62,11 @@ pub fn item_bytes(item: &Item) -> u64 {
.unwrap_or(0)
}
/// `history[..index]` までのトークン数を推定する。
/// Estimates the token count of `history[..index]`.
///
/// `prefix` は [`prefix_bytes`] で得た `history.len() + 1` 長の累積バイト列。
/// 呼び出し側が 1 度だけ計算して使い回すことで、線形探索や複数回の推定が
/// O(n) シリアライズで済む(内部で毎回再計算すると O(n²) になる)。
/// `prefix` contains `history.len() + 1` cumulative byte counts from [`prefix_bytes`].
/// Computing it once and reusing it keeps serialization for a scan or repeated
/// estimates O(n), rather than O(n²) from recomputing it for every estimate.
pub fn tokens_at(
history: &[Item],
records: &[UsageRecord],
@@ -91,7 +90,7 @@ pub fn tokens_at(
};
}
// exact match(rev 走査で一番新しい record を採用)
// Exact match: reverse iteration selects the most recent record.
if let Some(r) = records.iter().rev().find(|r| r.history_len == index) {
return TokenEstimate {
tokens: r.input_total_tokens,
@@ -153,15 +152,15 @@ pub fn tokens_at(
}
}
/// 現在の history 全体の推定トークン数。
/// Estimated token count for the entire current 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)` の形で使う。
/// Estimates the full prompt token count at an arbitrary history boundary.
/// Returns zero for `history_len == 0`. Differences such as
/// `total_tokens_at(now) - total_tokens_at(pointer)` can drive extraction triggers.
pub fn total_tokens_at(
history: &[Item],
records: &[UsageRecord],
+10 -11
View File
@@ -1,21 +1,20 @@
//! Per-LLM-request Usage measurement snapshot.
//! Per-LLM-request usage measurement snapshot.
//!
//! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を
//! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を
//! 受けて呼び出し側が組み立て、永続化層に流したり、token accounting
//! (`token_counter`) で履歴として参照したりする。
//! Records measured token usage for the history prefix included in one request.
//! The caller builds it from provider `UsageEvent`s and passes it to persistence
//! or to token accounting (`token_counter`).
/// LLM リクエスト送信時点での占有量スナップショット。
/// Usage snapshot for the history prefix sent with an LLM request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecord {
/// 送信時の history.len()
/// `history.len()` when the request was sent.
pub history_len: usize,
/// history[..history_len] の占有量(プロンプト全長、実測)
/// Measured total prompt tokens for `history[..history_len]`.
pub input_total_tokens: u64,
/// 上記のうちキャッシュから読み出された分
/// Portion of the input tokens read from cache.
pub cache_read_tokens: u64,
/// 上記のうちこのリクエストでキャッシュに書かれた分
/// Portion of the input tokens written to cache by this request.
pub cache_write_tokens: u64,
/// このリクエストで生成された出力トークン数
/// Output tokens generated by this request.
pub output_tokens: u64,
}
+21 -25
View File
@@ -1,15 +1,11 @@
//! Reasoning history round-trip 統合テスト
//! Integration tests for reasoning history round trips.
//!
//! Engine のストリーム → history append → 次リクエスト送出までの
//! ライフサイクルで `Item::Reasoning` が脱落せず保持されることを確認する。
//! Verifies that `Item::Reasoning` survives the lifecycle from Engine streaming
//! through history append to the next outgoing request.
//!
//! 検証点:
//! - Anthropic 由来の thinking + signature が `Item::Reasoning::signature` として
//! history に残る
//! - OpenAI Responses 由来の reasoning text + summary + encrypted_content が
//! `Item::Reasoning` の各フィールドに展開される
//! - 直前の reasoning は次の outgoing request の `request.items` の先頭付近に
//! 含まれる(assistant メッセージの先頭、Anthropic 仕様)
//! Covers Anthropic thinking and signatures, OpenAI Responses reasoning text,
//! summaries and encrypted content, and ordering reasoning before other assistant
//! output as required by Anthropic.
mod common;
@@ -41,8 +37,8 @@ fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Eve
]
}
/// Anthropic 風: thinking ブロック → text → 終了 のシーケンス。
/// Engine history に Reasoning(signature 付き) → assistant_message が並ぶ。
/// Anthropic-style sequence: thinking block, text, completion.
/// History contains Reasoning with a signature followed by assistant_message.
#[tokio::test]
async fn anthropic_thinking_round_trips_signature_into_history() {
let mut events = reasoning_block(
@@ -85,8 +81,8 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
assert_eq!(entries[2].item.as_text(), Some("Here's the answer"));
}
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
/// `Item::Reasoning` のフィールドに展開されること。
/// OpenAI Responses reasoning with encrypted_content and summary is projected
/// into the corresponding `Item::Reasoning` fields.
#[tokio::test]
async fn openai_reasoning_round_trips_encrypted_and_summary() {
let mut events = reasoning_block(
@@ -132,13 +128,13 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
}
}
/// Reasoning は assistant ターン内で text/tool_call より先に並ぶこと(Anthropic
/// が thinking を assistant メッセージの先頭に要求するため)。
/// Reasoning precedes text/tool_call in the assistant turn because Anthropic
/// requires thinking at the start of the assistant message.
#[tokio::test]
async fn reasoning_precedes_text_in_assistant_burst() {
let mut events = vec![
// text/tool_call とは独立に、reasoning block が中盤で完了しても、
// history append 時には assistant items の先頭に置かれる。
// Even if the reasoning block finishes after text/tool_call output,
// it is placed first among assistant items when appended to history.
Event::text_block_start(0),
Event::text_delta(0, "intermediate"),
Event::text_block_stop(0, None),
@@ -159,14 +155,14 @@ async fn reasoning_precedes_text_in_assistant_burst() {
let _out = engine.run(&mut history, "q").await;
let entries = history.entries();
// user / reasoning(先頭) / assistant_message
// user / reasoning (first assistant item) / assistant_message
assert!(matches!(entries[1].item, Item::Reasoning { .. }));
assert_eq!(entries[2].item.as_text(), Some("intermediate"));
}
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
/// 注入して run しても、次の outgoing request の `Request::items` にそのまま
/// 載って LLM へ渡る(engine は items を改変しない契約)。
/// Restored history: inject an Item::Reasoning with a signature as if restored
/// from history.json. Running the Engine preserves it in the next `Request::items`
/// sent to the LLM without modifying the reasoning item.
#[tokio::test]
async fn injected_reasoning_survives_into_outgoing_request() {
use async_trait::async_trait;
@@ -176,7 +172,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
use agen::llm_client::{ClientError, LlmClient, Request};
/// Request を 1 度だけキャプチャして空ストリームを返す client。
/// Captures a Request and returns a stream containing only completion status.
#[derive(Clone)]
struct CapturingClient {
captured: Arc<Mutex<Option<Request>>>,
@@ -208,7 +204,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
let mut engine = Engine::new(client);
let mut history: History = History::new();
// resume: 既存 history を流し込む
// Restore existing history before continuing.
engine.set_history(
&mut history,
vec![
@@ -225,7 +221,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
.unwrap()
.take()
.expect("client should have received a request");
// Reasoning item が outgoing items に保持されていること
// The reasoning item must remain in the outgoing items.
let mut found = false;
for item in &req.items {
if let Item::Reasoning {
+5 -5
View File
@@ -1,7 +1,7 @@
//! HTTP transport の単発 request / error classification テスト。
//! Tests for single-attempt HTTP requests and error classification.
//!
//! Retry/backoff は Engine の lifecycle 管理に属するため、transport は 1 回だけ
//! request を送り、HTTP status / Retry-After を `ClientError` に載せて返す。
//! Retry and backoff belong to the Engine lifecycle. The transport sends one
//! request and returns the HTTP status and Retry-After information in `ClientError`.
use agen::llm_client::LlmClient;
use agen::llm_client::auth::AuthRequirement;
@@ -17,8 +17,8 @@ use std::time::Duration;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// SSE 本体は触らないテスト用 scheme。`parse_fail` を立てると
/// stream 消費中で `ClientError::Sse` を返す。
/// Test scheme that does not inspect SSE payloads. With `parse_fail` set,
/// it returns `ClientError::Sse` while consuming the stream.
#[derive(Clone)]
struct DummyScheme {
parse_fail: bool,