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
+5 -10
View File
@@ -1,9 +1,10 @@
すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
## このシステムに置ける設計要旨
## 文書と設計
- プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
文書の追加・変更・削除は[文書管理規則](docs/README.md)に従う。設計理由は所有するRust module・itemのrustdocへ置き、crate READMEは短い概要と参照にする。利用ガイドや設定説明は追加しない。
変更量を最小にするために設計を歪めたり、不必要な後方互換性を作ったりしない。長期的な保守性と型安全性を優先する。
### 状態契約の命名・競合制御
@@ -15,13 +16,7 @@
### LLM コンテキストの加工原則
LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る。
一時的にメッセージを差し込む等の、揮発性の有るコンテキストの改変や、過去のメッセージを改ざんすることは基本的に禁止されている。
これを行うと、 LLM はそのコンテキストに基づいて生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
過去のコンテキストの圧縮は、キャッシュ破壊とトークン消費のトレードオフであり、必要であれば行っている。
しかし、キャッシュを破壊するタイミングと頻度は正確にコントロールされる必要があり、実際のセッションデータの解析に基づいて慎重に設計されるべきである。
[正本・権限・履歴の原則](docs/principle/authority-and-history.md)に従う。LLM入力へ影響する情報は履歴へ先に確定し、永続化形式から再構成できるようにする。圧縮の保証と限界は所有moduleに記述し、実測に基づいて変更する。
---
+11 -80
View File
@@ -1,85 +1,16 @@
# 夜居 / Yoi agent
# 夜居 / Yoi
Yoi is an agent runtime for building, running, and orchestrating LLM Workers while preserving explicit history, scoped capabilities, and developer-controlled workflows.
Yoiは、LLM Workerの実行履歴と明示的な権限を管理するエージェント実行基盤である。Workspaceの記録と操作権限をBackendが管理し、RuntimeがWorkerと作業ディレクトリの実行資源を管理する。単一プロセスで実行するStandaloneは、Workspaceの権限やRuntime Workerの登録とは分離している。
## 1. Yoi agent
## 実装の入口
Yoi focuses on long-running agent operation rather than one-off prompt execution. A named Worker can keep durable session history, run with explicit tool and filesystem authority, delegate bounded work to child Workers, and be inspected or restored through CLI/TUI surfaces.
- [agen](crates/agen/README.md):LLMターン、履歴、ツール呼出し。
- [worker](crates/worker/README.md):セッション、機能、ツールと実行権限の接続。
- [workspace-server](crates/workspace-server/README.md):Workspaceの正本とBackend操作。
- [worker-runtime](crates/worker-runtime/README.md):Workerの実行とRuntime資源。
- [standalone](crates/standalone/README.md):プロセス内の独立した実行ホスト。
- [yoi](crates/yoi/README.md)・[tui](crates/tui/README.md)・[Web](web/workspace/README.md):利用者との接点。
Main highlights:
各crateのREADMEをcrateドキュメントとして使い、具体的な設計理由はmodule・itemのrustdocに置く。文書の追加・維持・削除は[文書管理規則](docs/README.md)、変更の検証は[検証規則](docs/development/validation.md)に従う。
- Named long-running **Workers** with durable session and metadata records.
- Explicit tool permissions and filesystem scopes.
- Multi-agent orchestration with scoped coder/reviewer Workers.
- Profile, Manifest, and prompt-based runtime configuration.
- Local Tickets and workflow files for auditable project coordination.
- TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Worker Console.
Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change.
## 2. Quick Install
From source:
```sh
cargo build --release -p yoi
./target/release/yoi --help
```
With Nix:
```sh
nix build .#yoi
./result/bin/yoi --help
```
## 3. Getting Started
```sh
yoi --help
yoi
yoi panel
yoi --worker <name>
yoi worker --help
```
Typical flow:
1. Configure providers, models, profiles, prompts, and scopes.
2. Start or attach to a named Worker in the Console, or inspect workspace activity in the Dashboard.
3. Use explicit tools and scoped delegation for multi-agent work.
4. Record project work through Tickets, workflow files, and git history.
Client and execution surfaces use the `yoi` binaries, XDG client/server configuration and data directories, Runtime-owned Workdirs/session artifacts, Server data directories, `YOI_*`, and the preserved legacy home secret-store boundary under `~/.yoi`. Repository-local `.yoi` trees are ignored legacy input, not runtime authority.
## 4. Documentation
Start with [`docs/README.md`](docs/README.md).
Key docs:
- [`docs/design/overview.md`](docs/design/overview.md) — architecture and crate ownership map.
- [`docs/design/context-history.md`](docs/design/context-history.md) — history/context invariants.
- [`docs/design/worker-session-state.md`](docs/design/worker-session-state.md) — Worker identity, metadata, and session logs.
- [`docs/design/profiles-manifests-prompts.md`](docs/design/profiles-manifests-prompts.md) — Profiles, Manifests, and prompt resources.
- [`docs/design/tool-permissions-scope.md`](docs/design/tool-permissions-scope.md) — tool policy and filesystem scope.
- [`docs/development/work-items.md`](docs/development/work-items.md) — Ticket workflow and project records.
- [`docs/development/validation.md`](docs/development/validation.md) — validation expectations.
## 5. Development
This repository dogfoods Yoi to develop Yoi. Work is tracked through Workspace Server-backed Tickets and `yoi ticket ...`; Git history plus append-only Ticket and Merge Request evidence are the authoritative project record. Repository-local `.yoi/tickets` trees are ignored legacy input.
Common checks:
```sh
yoi ticket doctor
git diff --check
cargo fmt --check
cargo check --workspace --all-targets
cargo test --workspace
```
Real-process E2E testing is opt-in. Do not design or implement E2E coverage unless the work explicitly requires E2E; otherwise protect the narrower parser, API, protocol, authority, or runtime boundary.
License: MIT. See [`LICENSE`](LICENSE).
ライセンスは[MIT](LICENSE)。
+5 -28
View File
@@ -1,32 +1,9 @@
# agen-macros
Procedural macros used by [`agen`](https://crates.io/crates/agen) to declare LLM tools from Rust methods.
`agen-macros` generates `agen` tool definitions and implementations from Rust
method declarations. It isolates procedural macro expansion from `agen`, which
owns tool registration and execution.
Applications should normally depend only on `agen` and import its re-exports:
## Design entry points
```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)
}
}
```
`#[tool_registry]` generates the argument schema, a `Tool` implementation, and an `<method>_definition` constructor. It rejects arguments of its own, duplicate `#[tool]` markers, malformed or duplicate `#[description = "..."]` attributes, and non-identifier argument patterns.
Generated code targets the canonical `::agen` path and uses implementation dependencies re-exported by `agen`; consumers do not need direct `serde`, `schemars`, `serde_json`, or `async-trait` dependencies. Renaming the `agen` dependency in `Cargo.toml` is not currently supported.
This companion package is published before the matching `agen` release. Its public contract is the generated API consumed by `agen`, and its minor version compatibility follows the `agen` 0.2 series.
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
- [`tool_registry`]: declaration validation, argument schemas, and execution wrappers
+9 -28
View File
@@ -1,9 +1,4 @@
//! Procedural macros for declaring [`agen`](https://docs.rs/agen) tools.
//!
//! [`tool_registry`] expands methods marked with `#[tool]` into `agen::tool::Tool`
//! implementations and tool definitions. Applications normally use the re-exports from
//! `agen`; this companion crate exists so those macros can be published and versioned
//! independently.
#![doc = include_str!("../README.md")]
use proc_macro::TokenStream;
use quote::{format_ident, quote};
@@ -12,30 +7,16 @@ use syn::{
spanned::Spanned,
};
/// Generates tools for methods marked with `#[tool]` in an `impl` block.
/// Validates tool declarations and generates JSON Schema and `agen` execution wrappers.
///
/// Method doc comments become the tool description. An argument can use
/// `#[description = "..."]` to supply its JSON Schema description.
/// Method doc comments and argument `description` attributes provide schema descriptions.
/// Invalid attributes, duplicate markers, and non-identifier argument patterns produce
/// `compile_error!` diagnostics rather than panicking during macro expansion.
///
/// ```ignore
/// #[derive(Clone)]
/// struct MyApp;
///
/// #[agen::tool_registry]
/// impl MyApp {
/// /// Retrieves a user by ID.
/// #[tool]
/// async fn get_user(
/// &self,
/// #[description = "The user ID"] user_id: String,
/// ) -> Result<String, std::io::Error> {
/// todo!()
/// }
/// }
/// ```
///
/// This generates a `ToolGetUser` wrapper, a `GetUserArgs` schema type, and
/// `MyApp::get_user_definition()`.
/// Generated code references helper dependencies through `::agen::__private` so
/// downstream crates do not need to depend on implementation details directly.
/// The expansion uses the canonical `::agen` path and does not resolve renamed dependencies.
/// `ToolExecutionContext` is supplied by the executor and excluded from model input schemas.
#[proc_macro_attribute]
pub fn tool_registry(attr: TokenStream, item: TokenStream) -> TokenStream {
let attr = proc_macro2::TokenStream::from(attr);
+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,
+7
View File
@@ -0,0 +1,7 @@
# api-macros-impl
Procedural macro implementation for `api-macros`. It validates and normalizes trait declarations before generating metadata and adapters. It does not own runtime HTTP handling or application authorization decisions.
## Main entry points
- [`api`] — declaration normalization and code generation
+8 -1
View File
@@ -1,4 +1,4 @@
//! Implementation of `api-macros`.
#![doc = include_str!("../README.md")]
use std::collections::{BTreeMap, BTreeSet};
@@ -15,6 +15,13 @@ use syn::{
spanned::Spanned,
};
/// Macro boundary that normalizes a trait before generating each output.
///
/// Validate routes, operation IDs, and body/response constraints before generation.
/// Generate HTTP, WebSocket, and OpenAPI from one normalized model rather than interpreting
/// them independently, reducing differences between adapters for the same declaration.
/// Validation failures are compile errors; unsupported wire shapes are not implicitly
/// accepted as JSON.
#[proc_macro_attribute]
pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream {
let attr = proc_macro2::TokenStream::from(attr);
+9
View File
@@ -0,0 +1,9 @@
# api-macros
Defines transport-independent API metadata from public traits. The same declarations drive optional HTTP adapters and OpenAPI generation, while callers retain authentication, authorization, and application state.
## Main entry points
- [`api`] — API declaration and validation
- [`ApiContract`], [`Operation`], and [`WebSocketOperation`] — typed operation contracts
- `axum`, `reqwest`, and `openapi` — adapters and document generation enabled by their respective features
+4 -5
View File
@@ -1,9 +1,8 @@
//! Axum support used by routers generated with `#[api(axum)]`.
//! Transport boundary for generated routers.
//!
//! Generated operation routers contain only transport extraction, typed service invocation, and
//! response mapping. Authentication/authorization context, body-limit policy, timeout policy, and
//! other application state remain explicit outer Axum layers. Each operation is also exposed as a
//! separate router so route-specific layers can be applied before routers are merged.
//! Routers only extract inputs, call typed services, and convert responses.
//! Outer layers retain authentication, authorization, body limits, timeouts, and application state.
//! Per-operation routers allow route-specific constraints to be applied before merging.
use std::{fmt, str::FromStr};
+66 -141
View File
@@ -1,147 +1,72 @@
//! Shared API trait declarations for Yoi services.
//!
//! `#[api]` turns a public trait into a single, transport-neutral API definition. The
//! original trait remains usable by Rust code; generated metadata and zero-sized operation
//! markers give adapters and generators a stable view of the same declaration.
//!
//! # Declaration syntax
//!
//! ```
//! # mod example {
//! use api_macros::api;
//!
//! pub struct CreateWidget;
//! pub struct Widget;
//! pub struct PublicError;
//!
//! #[api]
//! pub trait WidgetApi {
//! #[post(
//! "/widgets",
//! operation_id = "widgets.create",
//! status = 201,
//! error_status = 422
//! )]
//! async fn create(
//! &self,
//! #[body] request: CreateWidget,
//! #[header("x-request-id")] request_id: String,
//! ) -> Result<Widget, PublicError>;
//!
//! #[get("/widgets/{widget_id}", operation_id = "widgets.get")]
//! async fn get(&self, widget_id: u64) -> Result<Widget, PublicError>;
//! }
//! # }
//! ```
//!
//! Supported route attributes are `get`, `post`, `put`, `patch`, `delete`, `head`, and
//! `options`. The first argument is a path template. `operation_id` defaults to the Rust
//! method name but should be set explicitly for contracts which must remain stable while Rust
//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to
//! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP
//! outcomes share one schema. Operations whose successful statuses have different body or header
//! shapes use `responses = [(status = 200, body = Widget, headers = [("etag", String)]),
//! (status = 304, headers = [("etag", String)])]`; the macro emits a typed result enum in the
//! `<trait_name>_responses` module. Only explicit body-less `304` declarations extend success
//! beyond 2xx. A public error inferred from `Result<T, E>` defaults to status `400`;
//! `additional_error_statuses` publishes the same typed error schema for other declared outcomes.
//! `bearer_auth = true` and `browser_auth = true` attach standard bearer and browser-session cookie
//! security schemes. Body operations may opt into typed Axum rejection normalization with
//! typed Axum rejection normalization with `normalize_body_errors = true` and [`HttpRequestError`].
//!
//! Arguments are classified with `#[body]`, `#[binary]`, `#[query]`, `#[header]`, `#[path]`, or
//! `#[extension]`. Extension values are trusted server-local Axum context: generated clients and
//! OpenAPI omit them. An unannotated argument whose Rust name occurs in the route template is
//! inferred as a path argument. Header attributes may carry a wire name, as in
//! `#[header("x-request-id")]`. Exactly one JSON or binary body is allowed. JSON request, response,
//! and error bodies must be named Rust types; tuples, references, arrays, and other anonymous
//! structural types are rejected. Binary request bodies use [`BinaryBody`] and remain byte-exact.
//! A named success response whose final type identifier is `BinaryBody` is likewise byte-exact,
//! uses `application/octet-stream`, and may include declared response headers. Generated clients
//! collect binary responses under the same configured response byte limit as JSON responses.
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
//!
//! WebSocket routes use a declaration-only associated type rather than a dummy unary method:
//!
//! ```
//! # mod websocket_example {
//! # use api_macros::api;
//! # pub struct ClientFrame;
//! # pub struct ServerFrame;
//! #[api]
//! pub trait EventApi {
//! #[websocket(
//! "/events/{stream_id}",
//! operation_id = "events.stream",
//! method = GET,
//! client_to_server = ClientFrame,
//! server_to_client = ServerFrame,
//! path_parameters = [stream_id: u64]
//! )]
//! type EventStream;
//! }
//! # }
//! ```
//!
//! The declaration is removed from the emitted service trait and becomes a typed marker implementing
//! [`WebSocketOperation`]. It participates in the same operation/route inventory and path validation,
//! but never generates a Reqwest method, unary Axum adapter, or OpenAPI path. Under the `axum`
//! feature, [`axum::websocket_route`] mounts an existing manual upgrade handler at its declared GET
//! path without exposing framework WebSocket types to the contract.
//!
//! # Generated names
//!
//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`],
//! plus a `widget_api_operations` module. That module contains one marker per Rust method
//! (`Create`, `Get`, ...), each implementing [`Operation`] and connecting metadata to concrete
//! body types at compile time. Unusual method names which have no valid PascalCase form use the
//! deterministic `Operation<utf8-hex>` fallback. The `ApiContract::OPERATIONS` inventory is
//! sorted by operation ID, so its ordering is independent of source method order.
//!
//! Empty, JSON, and bounded binary request and success response bodies are supported. [`WireKind`] reserves explicit
//! variants for other future transport work; accepting one requires a deliberate macro and adapter
//! change rather than silently treating it as JSON.
//!
//! # OpenAPI 3.1 export
//!
//! With the `openapi` feature, `#[api(openapi)]` emits a `<trait_name>_openapi` function from the
//! same normalized operation model used by the HTTP adapters. The caller supplies title, version,
//! and an opaque source digest through [`openapi::OpenApiInfo`], then either serves
//! [`openapi::OpenApiDocument::to_json`] or writes exactly those canonical bytes with
//! [`openapi::OpenApiDocument::write_json`] from a CLI. Documents contain no server URLs,
//! environment names, timestamps, or other deployment topology.
//!
//! Every exposed wire type must implement [`openapi::OpenApiSchema`] as an explicit assertion that
//! its `schemars::JsonSchema` contract matches Serde serialization. The trait's naming hook is the
//! public component-name contract. Different schemas claiming one name, unsafe-width integer
//! schemas, and ambiguous non-null `anyOf`/untagged representations fail closed. Use bounded
//! integers (for example `u32`) for JSON numeric fields, and model optionality separately from
//! nullability through object `required` membership and nullable schemas. A normalized
//! `#[header("authorization")]` string (or optional string) is projected as an HTTP bearer
//! security scheme and operation requirement rather than as a raw header parameter.
//!
//! # Optional HTTP adapters
//!
//! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this
//! crate's `reqwest` feature is enabled. `#[api(axum)]` generates `TraitNameAxum` plus one public
//! router per operation when the `axum` feature is enabled. `#[api(reqwest, axum)]` emits both
//! from the same normalized definition. A plain `#[api]` remains framework-independent and pulls
//! neither framework into DTO/contract-only builds.
//!
//! Generated clients use a fallible `try_new` constructor and builder. The builder validates an
//! absolute credential-free HTTP(S) base URL and configures request timeout and response byte
//! limit. A prevalidated [`reqwest::BaseUrl`] and policy-configured `reqwest::framework::Client`
//! may be injected instead. Request authorizers receive the final method, encoded path/query, and
//! exact serialized body bytes; credential headers and all body content are redacted from normal
//! diagnostics.
//!
//! Generated Axum adapters keep authentication, authorization, application state, body limits,
//! and timeout layers outside the adapter. Callers may layer the complete router or an individual
//! generated operation router before merging it into their application.
#![doc = include_str!("../README.md")]
extern crate self as api_macros;
/// Normalize a public trait into a transport-independent, typed API contract.
///
/// Preserve the original Rust trait and generate operation markers and metadata from the
/// same declaration. HTTP adapters and OpenAPI use the same normalized result, avoiding
/// manual synchronization of separate route lists. Sort the inventory by operation ID so
/// declaration reordering does not change the external contract order.
///
/// Validate request/response wire shapes at declaration time; unsupported types and duplicate
/// routes are compile errors. Exclude server-local extensions from clients and OpenAPI;
/// leave authentication, authorization, body limits, and timeouts to the enclosing application.
/// Declarations without adapters do not depend on an HTTP framework.
///
/// This declaration checks that a Rust trait and typed operation markers can be defined together.
///
/// ```
/// # mod example {
/// use api_macros::api;
///
/// pub struct CreateWidget;
/// pub struct Widget;
/// pub struct PublicError;
///
/// #[api]
/// pub trait WidgetApi {
/// #[post(
/// "/widgets",
/// operation_id = "widgets.create",
/// status = 201,
/// error_status = 422
/// )]
/// async fn create(
/// &self,
/// #[body] request: CreateWidget,
/// #[header("x-request-id")] request_id: String,
/// ) -> Result<Widget, PublicError>;
///
/// #[get("/widgets/{widget_id}", operation_id = "widgets.get")]
/// async fn get(&self, widget_id: u64) -> Result<Widget, PublicError>;
/// }
/// # }
/// ```
///
/// WebSocket associated types are declarations only: remove them from the output trait
/// and turn them into [`WebSocketOperation`] markers. They are not unary HTTP operations
/// and generate neither client methods nor OpenAPI paths.
///
/// ```
/// # mod websocket_example {
/// # use api_macros::api;
/// # pub struct ClientFrame;
/// # pub struct ServerFrame;
/// #[api]
/// pub trait EventApi {
/// #[websocket(
/// "/events/{stream_id}",
/// operation_id = "events.stream",
/// method = GET,
/// client_to_server = ClientFrame,
/// server_to_client = ServerFrame,
/// path_parameters = [stream_id: u64]
/// )]
/// type EventStream;
/// }
/// # }
/// ```
pub use api_macros_impl::api;
#[cfg(feature = "axum")]
+6 -4
View File
@@ -1,8 +1,10 @@
//! Deterministic OpenAPI 3.1 projection for `#[api(openapi)]` contracts.
//! Generate deterministic OpenAPI documents from API contracts.
//!
//! The projection deliberately has no deployment topology (`servers`) and no
//! generated timestamps. Callers supply an opaque source digest, which is
//! retained as provenance in `info.x-yoi-source-digest`.
//! Omit deployment URLs and generation timestamps; retain the caller's source digest as
//! provenance. This separates contract changes from environment changes and produces the
//! same bytes from the same inputs. Owners explicitly assert wire-schema/Serde agreement
//! through [`OpenApiSchema`]; name collisions, unsafe integers, and ambiguous schemas are
//! not silently published.
use std::{
borrow::Cow,
+4 -4
View File
@@ -1,8 +1,8 @@
//! Reqwest transport support used by clients generated with `#[api(reqwest)]`.
//! Transport boundary for generated clients.
//!
//! The generated builder validates the base URL before it can be stored. Callers that enforce
//! stricter egress policy (custom DNS resolution, proxy policy, TLS roots, or connection pinning)
//! can construct their own [`reqwest::Client`] and inject it with the validated [`BaseUrl`].
//! Validate the base URL before storing it; apply timeouts and byte limits to requests
//! and response reads. Stricter outbound constraints such as DNS, proxy, and TLS policy
//! remain at the boundary where callers inject their own [`reqwest::Client`] with [`BaseUrl`].
use std::{fmt, time::Duration};
+8
View File
@@ -0,0 +1,8 @@
# builtin-source
Catalog of public Profile sources embedded at compile time and shared across native/WASM. It exposes only explicit assets and does not discover sources from host files or the network.
## Main entry points
- [`BUILTIN_PROFILE_RESOURCES`] and [`BuiltinProfileResource`] — sources and import relationships
- [`builtin_profile_catalog_snapshot`] and [`BuiltinProfileCatalogSnapshot`] — fixed catalog contents and content identity
+11 -1
View File
@@ -1,4 +1,5 @@
//! Explicit compile-time public Decodal source catalog, shared by native and WASM hosts.
#![doc = include_str!("../README.md")]
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
@@ -33,6 +34,11 @@ const DEFAULT_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
}];
const NO_IMPORTS: &[BuiltinProfileImport] = &[];
/// Fixed catalog of public embedded Profile sources and import relationships.
///
/// Only the sources listed here are shared across native/WASM, not arbitrary resources.
/// Launch selectors identify entry points; paths identify assets for imports.
/// Publishing sources alone neither launches Workers nor grants permissions.
pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
BuiltinProfileResource {
selector: None,
@@ -150,6 +156,10 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
},
];
/// Catalog snapshot fixing source bytes, selector entry points, and import relationships together.
///
/// [`Self::digest`] identifies all ordered contents and the catalog chosen by the consumer.
/// It is not information for replacing values already evaluated in a Workspace with the current catalog.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuiltinProfileCatalogSnapshot {
pub id: &'static str,
+6 -26
View File
@@ -1,30 +1,10 @@
# client
## Role
Shared client for Backend Workspace/Runtime APIs and the Worker protocol. Provides authenticated communication and typed connection targets for CLI/TUI. The standalone crate owns Standalone execution, Runtime and the controller own Worker state and input admission, and higher-level crates own presentation and product commands.
`client` contains reusable socket-client and runtime-command mechanics for talking to Workers from CLI/TUI code.
## Implementation entry points
## Boundaries
Owns:
- one-shot Worker socket client behavior
- request/reply delivery mechanics
- runtime command construction below the product façade
- shared attach/status probing helpers used by higher layers
Does not own:
- product command names (`yoi`)
- Worker state authority (`worker`, `session-store` worker metadata)
- UI rendering (`tui`)
- Engine turn semantics (`agen`)
## Design notes
The client boundary lets `tui` and `yoi` share Worker communication without making library crates depend on the product binary. Socket clients should drain connect-time snapshot/alert traffic before sending a method or deciding status.
## See also
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
- [`../../docs/design/overview.md`](../../docs/design/overview.md)
- [`BackendApiClient`] — Authenticated HTTP/WS requests bound to an origin
- [`backend_runtime`] / [`backend_workspace`] — Backend resource retrieval and operations
- [`Client`] / [`transport`] — Method/Event encoding and message transport
- [`target`] — Distinct Backend and Standalone targets and operation intents
+11
View File
@@ -1,3 +1,14 @@
//! Credentials and HTTP/WS requests bound to a Backend origin.
//!
//! BackendOrigin normalizes scheme/host/port and rejects inputs with a path, query, fragment,
//! or userinfo. Stored tokens are selected by origin; cross-origin redirects are rejected
//! to prevent credential leaks. WS requests use the same origin and bearer token.
//!
//! This client uses human Backend API tokens, not Workspace signing capabilities or Runtime
//! source proofs. It returns 401/403 as authentication/authorization failures without falling
//! back to a local Runtime or another token. Callers interpret typed results; transport success
//! does not imply completion of a domain operation.
use chrono::{DateTime, Utc};
use reqwest::{Method, StatusCode, Url, redirect};
use serde::{Deserialize, Serialize};
+10
View File
@@ -1,3 +1,13 @@
//! Runtime Worker operations and observation through the Backend.
//!
//! Targets are Workspace/Runtime/Worker tuples sent to scoped Backend APIs. Runtime endpoints
//! and display names do not confer execution authority on the client. HTTP management reuses
//! server-api DTOs; protocol connections carry Method/Event messages over Backend WS.
//!
//! Restore sends the caller's fixed observation token/request ID unchanged. Response loss does
//! not imply rollback; there is no token replacement, automatic Restore, or reconnection to
//! the next execution. Initial snapshots and notices are observations, not input admission.
use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError};
use crate::{BackendApiClient, BackendApiClientError, Client};
pub use server_api::{
+8
View File
@@ -1,3 +1,11 @@
//! Generated client adapter for the Backend Workspace catalog.
//!
//! Workspace creation passes an operation key and optional initial Repository to the Server,
//! without local Git discovery or repository initialization. Empty Repository lists are valid.
//! The Server determines creation idempotency, ownership, and current-state replay; this client
//! keeps no separate creation ledger. It classifies 401/403 as shared Backend authentication
//! errors, distinct from other domain errors.
use crate::{BackendApiClient, BackendApiClientError};
use server_api::{
InitialRepositoryIntent, RepositorySummary, WorkspaceCreateRequest, WorkspaceCreateResponse,
+6 -1
View File
@@ -6,7 +6,12 @@ use protocol::{Event, Method};
use crate::transport::Socket;
/// Typed Worker protocol client over an injected message transport.
/// Typed Worker protocol client over a message transport.
///
/// Encodes and sends Methods and decodes received messages into Events. Successful sends
/// indicate transport delivery, not controller command acknowledgments or durable Submit/Notify
/// receipts. Callers interpret events; connection snapshots are not consumed automatically
/// and state is not inferred.
pub struct Client<T> {
socket: T,
}
+1 -4
View File
@@ -1,7 +1,4 @@
//! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。
//!
//! Standalone execution is owned by the `standalone` crate and does not spawn
//! a Worker subprocess through this crate.
#![doc = include_str!("../README.md")]
pub mod backend_api;
mod backend_auth;
+7
View File
@@ -1,3 +1,10 @@
//! Distinct Backend and Standalone targets and product operation intents.
//!
//! Backend targets resolve an origin and Workspace and identify Workers by Runtime/Worker pairs.
//! Standalone spawn/list/resume are intents passed to higher-level crates, not execution;
//! this module starts no subprocesses. Target identity is separate from product commands
//! and Worker state authority.
use std::{fmt, path::PathBuf};
use crate::{
+7 -3
View File
@@ -1,3 +1,8 @@
//! Message transport for one Worker connection.
//!
//! Separates in-process, Unix socket, and WS framing from protocol Method/Event encoding.
//! Changing transports does not change ownership of Worker execution, input admission, or receipts.
use std::error::Error;
use async_trait::async_trait;
@@ -6,10 +11,9 @@ pub mod in_process;
pub mod unix_socket;
pub mod websocket;
/// Message-oriented transport for one Worker protocol connection.
/// UTF-8 message transport for a Worker protocol connection.
///
/// Implementations own physical framing. `client::Client` owns the typed
/// Method/Event protocol encoding layered on top of these UTF-8 messages.
/// Implementations own physical framing; the higher-level Client encodes and decodes Method/Event.
#[async_trait]
pub trait Socket {
type Error: Error + Send + Sync + 'static;
+9
View File
@@ -0,0 +1,9 @@
# config-source-wasm
WASM adapter exposing `config-source` snapshot evaluation and editing support to JavaScript. It handles browser-local edit candidates without changing Backend save permissions or persistent state.
## Main entry points
- [`set_snapshot`] and [`apply_changes`] — update local edit candidates
- [`evaluate_snapshot`] and [`evaluate_current`] — evaluation through the shared implementation
- [`analyze_snapshot`] and [`complete_current`] — diagnostics and completion
+10
View File
@@ -1,3 +1,5 @@
#![doc = include_str!("../README.md")]
use config_source::{
ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult,
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
@@ -17,6 +19,10 @@ pub fn compose_schema_bundle(contributions: JsValue) -> Result<JsValue, JsValue>
encode(WorkspaceConfigSchemaBundle::compose(contributions).map_err(js_error)?)
}
/// Retain the browser's edit candidate as a snapshot.
///
/// Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
/// Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
#[wasm_bindgen]
pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> {
let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
@@ -85,6 +91,10 @@ struct WasmCompletionItem {
priority: i32,
}
/// Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
///
/// Convert the returned start position back to UTF-16 so non-ASCII characters do not
/// misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
#[wasm_bindgen]
pub fn complete_current(
entrypoint: String,
+9
View File
@@ -0,0 +1,9 @@
# config-source
Shares the virtual Workspace configuration source tree, Decodal evaluation, diagnostics, and completion across native/WASM. Inputs are content-addressed snapshots, not a mechanism for reading host configuration directories. Callers own persistence, save-time authorization, and whole-tree CAS.
## Main entry points
- [`VirtualPath`] and [`ConfigTreeSnapshot`] — virtual paths and source-change validation
- [`WorkspaceConfigSchemaBundle`] and [`ToolchainContract`] — evaluation-condition identity
- [`SnapshotEnvironment`] and [`SnapshotImportLoader`] — evaluation and closed import resolution
+57 -5
View File
@@ -1,3 +1,5 @@
#![doc = include_str!("../README.md")]
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::{Arc, Mutex};
@@ -37,6 +39,11 @@ impl<'de> Deserialize<'de> for VirtualPath {
}
}
/// Relative path identifying an entry in the virtual source tree, not a host path.
///
/// Reject absolute paths, URLs, empty components, `.` and `..`. [`resolve_import`] resolves
/// relative imports against the containing virtual directory, independent of the storing
/// Backend's cwd or OS directory structure.
impl VirtualPath {
pub fn parse(value: impl AsRef<str>) -> Result<Self, ConfigTreeError> {
let value = value.as_ref();
@@ -299,6 +306,15 @@ impl<'de> Deserialize<'de> for ConfigTreeSnapshot {
}
}
/// Virtual tree identified by the contents of author-saved sources.
///
/// Keep entry points, import targets, and text assets in one tree. Evaluated Profiles and prompts
/// do not replace these sources. The digest identifies paths, content types, and complete bytes,
/// not edit counts or freshness. External deserialization also verifies contents against the digest.
///
/// [`Self::apply`] creates a candidate without changing the original snapshot. Save permissions,
/// persistence, and conflict detection with the UI or multiple Workers lie outside this type;
/// callers must check the authoritative tree's whole digest and evaluation contract at the save boundary.
impl ConfigTreeSnapshot {
pub fn empty() -> Self {
Self::from_entries(Vec::new()).expect("empty config snapshot is valid")
@@ -403,9 +419,11 @@ impl ConfigTreeSnapshot {
changes
}
/// Apply an atomic batch. Each expected_digest compares the target's complete
/// source bytes only; it is not a tree precondition. Callers needing whole-tree
/// CAS must also compare the authoritative tree digest under their write lock.
/// Check source-byte preconditions only and return a candidate for the entire change batch.
///
/// The original snapshot remains unchanged even if a change fails. `expected_digest` compares
/// only entry contents, not whole-tree CAS. Saving must separately compare the authoritative
/// whole-tree digest under a write lock or within a transaction.
pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result<Self, ConfigTreeError> {
self.validate()?;
if changes.len() > MAX_CHANGE_COUNT {
@@ -591,6 +609,11 @@ impl ConfigSchemaContribution {
})
}
/// Optional editor shape for completion and non-materializing constraint analysis.
///
/// Evaluate saved projections with `source`. Do not fill omitted values from this shape or
/// require omitted fields in partial Profiles. Include its contents in the bundle fingerprint
/// to identify the combination of storage schema and editor shape.
pub fn with_authoring_source(mut self, source: impl Into<String>) -> Self {
self.authoring_source = Some(source.into());
self
@@ -750,6 +773,12 @@ pub struct ToolchainContract {
pub fingerprint: String,
}
/// Contract identifying the schema, toolchain, and import policy used to evaluate a source.
///
/// Separate source digests from evaluation conditions: a schema update is not the same
/// evaluation even when source bytes are unchanged. Persistence must manage the sources,
/// this contract, and the projection together. This type neither saves configuration nor
/// applies it to existing Workers.
impl ToolchainContract {
pub fn new(
schema_version: u32,
@@ -1111,6 +1140,13 @@ fn pop_container(
}
}
/// Closed environment for evaluating and analyzing virtual source snapshots.
///
/// Separate input sources from schema contracts; return evaluation results as JSON projections
/// and their digest. Evaluation does not discover configuration sources in host files,
/// environment variables, or the network. Native and WASM share this implementation, so editor
/// analysis does not use a separate storage schema. Constraint analysis of editor shapes is
/// non-materializing and does not add defaults to saved projections.
#[derive(Debug, Clone)]
pub struct SnapshotEnvironment {
snapshot: ConfigTreeSnapshot,
@@ -1141,8 +1177,11 @@ impl SnapshotEnvironment {
self.evaluate_with_builtin_sources(contract, Arc::default())
}
/// Capture only builtin sources actually loaded by evaluation, including transitive imports.
/// These immutable bytes are provenance for the evaluated value, not Runtime inputs.
/// Retrieve original bytes for transitive builtin dependencies actually loaded during evaluation.
///
/// This is provenance for evaluated values, not an instruction to reevaluate in the Runtime.
/// Saving and launching can pin values and dependency sources by digest, avoiding implicit
/// replacement with current builtins. Archive construction and validation happen outside this type.
pub fn builtin_import_sources(
&self,
contract: &ToolchainContract,
@@ -1597,6 +1636,19 @@ impl HostEnvironment for ConfigAuthoringEnvironment<'_> {
}
}
/// Import loader returning only snapshot sources and explicitly published builtins.
///
/// Resolve relative imports against the containing virtual directory; keep relative imports
/// within builtins in the builtin namespace. Do not use host paths to supply missing entries
/// or compensate for namespace escapes. Builtins are read-only; same-name Workspace entries
/// cannot override them.
///
/// Check import-graph cycles and depth before loading, including imports in lazy fields.
/// Builtins share the snapshot's entry-count and byte budgets. Count repeated loads of the
/// same builtin once and include unused Workspace entries in the budget.
/// Use virtual paths for diagnostics and relative resolution. Engine cache identities also
/// include interpretation and content digests, so changed content or content types cannot
/// be confused with previous cached entries.
#[derive(Debug, Clone)]
pub struct SnapshotImportLoader {
snapshot: ConfigTreeSnapshot,
+7 -24
View File
@@ -1,28 +1,11 @@
# feature-storage
Neutral Host-owned SQLite lifecycle manager for trusted Features. Owns placement,
WAL/connection settings, transaction serialization, migrations, snapshots,
restore/delete fencing, and shutdown. Features receive a managed
`FeatureDatabase` after registration. Worker/model input never selects a database
path or receives SQL access.
Manages the SQLite lifecycle for Features within a trusted Host.
Does not own Feature domain schemas or authentication, or provide SQL access to Workers or models.
Use `FeatureStorage::new(trusted_root)`, `register(FeatureRegistration)`, and
`scope(host_issued_scope_id).open(&registration)`. `ScopedFeatureStorage` and
`FeatureDatabase::scope_id()` are neutral storage isolation APIs: a local Host's
persisted random scope does not create a Workspace record or imply Backend
membership. Existing placement `<root>/<scope>/features/<feature>.sqlite` and
snapshot `workspace=` keys remain unchanged for physical compatibility.
## Main entry points
`WorkspaceFeatureStorage`, `workspace()`, `workspace_id()`, and
`for_server_database()` are Backend compatibility aliases/helpers. They do not
introduce a Server dependency. The Host is responsible for state-root privacy,
symlink/path validation, and external execution leases before constructing this
trusted manager.
`configure_connection(&rusqlite::Connection)` is reusable by other Host stores
(such as JobStore). It sets the five-second busy timeout, foreign keys ON, WAL,
and synchronous FULL before migrations/transactions. The Feature manager uses
this same helper; WAL lock-contention retries stay within the busy timeout.
Dependencies: `rusqlite` (existing workspace backup support), `thiserror`, `uuid`;
tests use `tempfile`.
- [`FeatureStorage`], [`ScopedFeatureStorage`]: registration, scope isolation, storage, and shutdown
- [`FeatureDatabase`]: connections and transactions
- [`FeatureMigration`]: Feature-owned schema changes
- [`configure_connection`]: shared SQLite connection policy
+45 -27
View File
@@ -1,9 +1,4 @@
//! Host-owned SQLite storage for trusted Features.
//!
//! The Host owns database placement, connection configuration, migrations, and
//! operational lifecycle. A Feature owns its schema and only receives a scoped
//! [`FeatureDatabase`] after trusted registration; Worker/model inputs never
//! select a path or receive a SQL surface.
#![doc = include_str!("../README.md")]
use std::collections::{HashMap, HashSet};
use std::fs;
@@ -79,6 +74,9 @@ impl FeatureStorageError {
}
}
/// Feature-owned schema changes. Commits pending changes and history in one IMMEDIATE transaction.
/// Failure rolls back both changes and history, allowing retry on the next open.
/// Rejects stored versions/names that differ from the plan and schemas newer than the plan.
#[derive(Clone, Copy)]
pub struct FeatureMigration {
version: u32,
@@ -135,6 +133,12 @@ impl RegisteredFeature {
}
}
/// Manages physical layout, connections, migrations, and shutdown within a trusted Host.
///
/// Features own schemas and domain transactions; the Host owns authentication before calls.
/// This separation is an in-process API boundary, not a sandbox for arbitrary native code.
/// The constructing Host owns state-root protection, path validation, and execution leases.
/// Registration alone creates no database and does not delete existing data for disabled Features.
#[derive(Clone)]
pub struct FeatureStorage {
inner: Arc<FeatureStorageInner>,
@@ -155,8 +159,8 @@ struct FeatureStorageState {
}
impl FeatureStorage {
/// Creates a lazy manager. No directories or databases are created until a
/// registered Feature is opened, restored, or explicitly backed up.
/// Lazy manager. Creates no directories or databases until a registered
/// Feature is opened, restored, or backed up.
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
inner: Arc::new(FeatureStorageInner {
@@ -184,8 +188,9 @@ impl FeatureStorage {
Ok(Self::new(parent.join("feature-storage")))
}
/// Binds trusted storage to a Host-issued opaque scope. This is a storage
/// isolation key, not a Workspace record or model authorization credential.
/// Restricts storage to a Host-issued scope.
/// A scope is a physical sharing boundary; it creates no Workspace record or model authorization credentials.
/// The same API supports Backend Workspace IDs and persistent local Host scope IDs.
pub fn scope(&self, scope_id: &str) -> Result<ScopedFeatureStorage> {
Ok(ScopedFeatureStorage {
manager: self.clone(),
@@ -198,6 +203,8 @@ impl FeatureStorage {
self.scope(scope_id)
}
/// Registers a schema once per manager. The returned registration is reusable across scopes.
/// Rejects duplicate and foreign-manager registrations, fixing the migration plan used on open.
pub fn register(&self, registration: FeatureRegistration) -> Result<RegisteredFeature> {
let feature_id = validate_identifier("Feature", registration.feature_id)?.to_string();
validate_migrations(&feature_id, registration.migrations)?;
@@ -215,8 +222,8 @@ impl FeatureStorage {
})
}
/// Closes every managed connection after any operation currently holding a
/// connection lock completes. New opens and operations then fail.
/// Stops admission, waits for operations holding connection locks, and closes all managed connections.
/// Retained handles also fail thereafter, so writes after shutdown cannot be treated as successful.
pub fn shutdown(&self) -> Result<()> {
let connections = {
let mut state = self.lock_state()?;
@@ -471,6 +478,7 @@ impl Drop for FeatureStorageInner {
}
}
/// Feature storage restricted to a Host scope. Scope identity denotes only a storage boundary.
#[derive(Clone)]
pub struct ScopedFeatureStorage {
manager: FeatureStorage,
@@ -494,26 +502,33 @@ impl ScopedFeatureStorage {
self.manager.register(registration)
}
/// Reuses a managed connection, validating and applying migrations on first open.
/// Serializes concurrent opens within a manager; SQLite serializes migration writers across managers.
/// Syncs metadata-directory ancestors before opening SQLite. DB/WAL sync alone cannot
/// persist links to newly created ancestors, so a sync failure prevents connection registration.
pub fn open(&self, registration: &RegisteredFeature) -> Result<FeatureDatabase> {
self.manager.open(&self.scope_id, registration)
}
/// Creates a consistent online SQLite snapshot of every Feature database,
/// including data for Features that are currently disabled/unregistered.
/// Backs up each Feature DB, including disabled/unregistered ones, using SQLite online backup.
/// Avoids file copies that omit WAL; checks integrity and foreign keys before publishing by rename.
/// Each DB is internally consistent, but no common-point snapshot across DBs or Host authority is guaranteed.
/// Blobs outside the DB are not saved, so this is not a complete Drive backup.
pub fn backup(&self, destination: impl AsRef<Path>) -> Result<()> {
self.manager
.backup_scope(&self.scope_id, destination.as_ref())
}
/// Restores a snapshot only into an absent scope storage directory and
/// only while no Feature database for this scope is open.
/// Restores a snapshot only into an uncreated scope with no connections.
/// Rejects scope mismatches and corrupt DBs; publishes the validated temporary directory by rename.
pub fn restore(&self, snapshot: impl AsRef<Path>) -> Result<()> {
self.manager
.restore_scope(&self.scope_id, snapshot.as_ref())
}
/// Closes this scope's Feature connections and removes only its scoped
/// storage tree. This is the operation used by Workspace deletion.
/// Closes connections in the scope and deletes only its storage tree. Retries are idempotent.
/// Keeps the same manager's admission fence after physical deletion, preventing cached tasks
/// from recreating databases before the Host finalizes deletion. Host deletion reservation is a separate responsibility.
pub fn delete(&self) -> Result<()> {
self.manager.delete_scope(&self.scope_id)
}
@@ -523,6 +538,9 @@ impl ScopedFeatureStorage {
}
}
/// Host-managed synchronous SQLite connection. Async callers use a blocking executor.
/// Domain repositories retain this handle and never pass connections or SQL to Workers/Runtimes.
/// Locks serialize operations and shutdown on each connection; deletion/shutdown fences are checked before and after locking.
#[derive(Clone)]
pub struct FeatureDatabase {
inner: Arc<FeatureDatabaseInner>,
@@ -584,8 +602,9 @@ impl FeatureDatabase {
operation(connection)
}
/// Executes a Feature-owned multi-table operation atomically. Returning an
/// error rolls the transaction back; successful return commits it.
/// Updates multiple tables within one Feature DB in an IMMEDIATE transaction.
/// Commits after the closure succeeds; closure or commit failures never return success.
/// Does not guarantee atomic writes with the Host control-plane DB or other Feature DBs.
pub fn transaction<T>(
&self,
operation: impl FnOnce(&Transaction<'_>) -> Result<T>,
@@ -593,8 +612,8 @@ impl FeatureDatabase {
self.try_transaction(operation)
}
/// Variant of [`Self::transaction`] that preserves a Feature repository's
/// typed domain errors while still rolling every failure back atomically.
/// Preserves the rollback, connection ownership, and fences of [`Self::transaction`],
/// returning typed Feature domain errors without converting them to storage strings.
pub fn try_transaction<T, E>(
&self,
operation: impl FnOnce(&Transaction<'_>) -> std::result::Result<T, E>,
@@ -742,11 +761,10 @@ fn sync_directory(path: &Path) -> std::io::Result<()> {
fs::File::open(path)?.sync_all()
}
/// Applies the common durable Host SQLite connection policy: five-second busy
/// timeout, foreign keys, WAL, and FULL synchronous commits. Call immediately
/// after opening a file-backed database, before transactions or migrations.
/// WAL initialization retries lock contention within the same bounded timeout.
/// This does not grant path authority or select a database schema.
/// Applies the shared SQLite connection policy before migrations or transactions.
/// Enables foreign keys, WAL, and FULL synchronous commits, with bounded lock-contention waits.
/// Retries WAL initialization within the same wait limit and rejects silent fallback to another journal mode.
/// Grants neither path-selection authority nor domain schemas.
pub fn configure_connection(connection: &Connection) -> Result<()> {
connection.busy_timeout(BUSY_TIMEOUT)?;
connection.execute_batch(
+10
View File
@@ -0,0 +1,10 @@
# flow
`flow` compiles DCDL state graphs into typed definitions and evaluates transitions against pinned definitions and verification results. Callers own Workspace source persistence, Worker Session writes, and internal verifier execution. This crate implements neither role-specific work loops nor state-entry side effects.
## Main entry points
- [`compile_flow_source`](src/definition.rs): Source evaluation and graph validation.
- [`FlowSelector`](src/selector.rs) / [`ResolvedFlowSource`](src/selector.rs): Explicit source identity and immutable snapshots.
- [`FlowRuntimeState`](src/coordinator.rs): Instances, transition attempts, and ordered events.
- [`builtin_flow_source`](src/builtin.rs): Read-only catalog of embedded sources.
+6
View File
@@ -1,3 +1,9 @@
//! Read-only catalog of embedded Flow sources.
//!
//! Provides exact source bytes without copying them into the Workspace DB or
//! maintaining manual update counters. The Runtime pins the compiled digest,
//! so resource updates do not change existing instance definitions.
use crate::{CompiledFlowDefinition, FlowCompileError, compile_flow_source};
pub const CODER_REVIEW_FLOW_SLUG: &str = "coder-review";
+22 -6
View File
@@ -1,3 +1,18 @@
//! Builds transition attempts from pinned Flow definitions and chooses destinations
//! from complete verdict sets.
//!
//! Callers do not specify a target state: an attempt pins every outgoing condition
//! of the current state. Each condition needs exactly one result, with no duplicate,
//! missing, or unknown IDs. Entry requires exactly one Met result and no
//! Indeterminate results. Verifier failures, cancellations, and incomplete responses
//! are not successful transitions.
//!
//! Checks the instance, active attempt, definition digest, and captured state so old
//! verifier results cannot apply to new attempts, even after returning to the same
//! state. Event sequences track append order, not freshness or source identity.
//! This module performs typed state transformations; callers own persistence,
//! verifier startup, and business side effects.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
@@ -198,11 +213,13 @@ pub struct FlowRuntimeEvent {
pub event: FlowEventKind,
}
/// Durable Flow authority owned by one Runtime Worker.
/// Flow state durably owned by one Runtime Worker.
///
/// Workspace authority resolves the exact source content, but never mutates
/// this value. Runtime persists the complete snapshot with the Worker session
/// and replaces it only after the corresponding session-log write succeeds.
/// Pins the resolved definition and digest to the instance, so source edits do not
/// change existing instances. Callers prepare new state on a clone and replace live
/// state only after a successful Session write. Recover verifying attempts after
/// restoration rather than creating competing attempts. Events reject reused attempt
/// IDs even after failure/cancellation. Stopping a Worker does not transfer state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FlowRuntimeState {
pub definition: CompiledFlowDefinition,
@@ -253,8 +270,7 @@ impl FlowRuntimeState {
))
}
/// Begin a new attempt, or return the persisted active attempt after a
/// Runtime/Worker restore. A recovered attempt is never rewritten.
/// Starts a new attempt or returns the restored active attempt unchanged.
pub fn begin_or_recover_transition(
&mut self,
attempt_id: impl Into<String>,
+16
View File
@@ -1,3 +1,19 @@
//! Compiles DCDL sources into typed Flow definitions.
//!
//! The source document is the graph authority; states and transitions are not split
//! into independently editable records. DCDL evaluation passes through private
//! Serde-compatible values into the source schema, then graph validation produces
//! a [`CompiledFlowDefinition`]. Intermediate JSON is neither a public API type nor
//! a persisted Runtime state type. Imports are rejected, and a single document's
//! content digest pins the execution definition.
//!
//! Validates schemas, identifiers, and targets; rejects unreachable states, closed
//! paths that cannot reach a user-defined terminal, outgoing terminal transitions,
//! and nonterminal states without transitions. Authored `$cancelled` states are
//! forbidden; synthetic cancellation transitions are excluded from normal-terminal
//! reachability checks. Only validated definitions receive a `$cancelled` terminal,
//! so exceptional cancellation cannot make an invalid normal-completion graph pass.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt;
use std::fmt::Write as _;
+1 -5
View File
@@ -1,8 +1,4 @@
//! Declarative Flow definitions and deterministic transition coordination.
//!
//! DCDL is a source format only. The Flow domain owns the typed source
//! schema, semantic validation, immutable transition snapshots, and state
//! transition rules.
#![doc = include_str!("../README.md")]
mod builtin;
mod coordinator;
+10 -4
View File
@@ -1,3 +1,10 @@
//! Explicit Flow source ownership and immutable source snapshots.
//!
//! `builtin:<slug>` and `workspace:<slug>` identify distinct logical records and may
//! coexist with the same slug. There is no implicit override order or unqualified
//! selector. Source resolution is separate from execution instance creation and
//! mutation; the Runtime pins the resolved result before execution.
use std::fmt;
use std::str::FromStr;
@@ -114,11 +121,10 @@ pub struct FlowSourceResolveRequest {
pub selector: FlowSelector,
}
/// Immutable source snapshot resolved by Workspace authority for one Runtime.
/// Immutable source snapshot resolved by the Workspace authority.
///
/// This is read-only source authority. Starting or mutating a Flow instance is
/// deliberately not part of this response; Runtime persists the snapshot in
/// the target Worker's durable state before execution.
/// This read-only source authority creates no instances, attempts, or events.
/// The Runtime commits the snapshot to the target Worker's Session before execution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedFlowSource {
pub selector: FlowSelector,
+11
View File
@@ -0,0 +1,11 @@
# fs-operation
Provides provider-side filesystem operations, bounded search, and pure text editing. Access checks and I/O for logical paths go through the provider's `FsAccessPolicy`. Does not own Workdir identity, Runtime transport, or Tool read history.
## Reference index
- [`FsAccessPolicy`] — provider access, I/O, and resource limits.
- [`CheckedTarget`] — checked saves retaining descriptors and identity.
- [`run_list`] — bounded live pagination.
- [`run_glob`] / [`run_grep`] — provider-side search.
- [`text`] — shared argument, UTF-8 byte limit, and edit result handling.
+17 -6
View File
@@ -1,9 +1,10 @@
//! Identity-fenced access adapter for the existing read/edit/write engine.
//! Identity-fenced access adapter for existing read/edit/write engines.
//!
//! Descriptor-rooted resolution is Linux-only and symlinks/mount crossings fail
//! closed. The final compare and atomic rename are NOT a kernel CAS against an
//! arbitrary external editor: external editors must cooperate with provider
//! serialization. A hostile rename in that last syscall gap is not excluded.
//! Retains target, save-parent, and provider-root descriptors to bind authorization to I/O.
//! Rejects unavailable Linux descriptor primitives, symlinks, and mount crossings.
//! Revalidates names/descriptors until just before save, but the final comparison and rename
//! are not kernel CAS. Does not guarantee against noncooperative external editors replacing
//! entries between those syscalls, or guarantee parent-directory crash durability.
use crate::{AtomicWriteMode, FsAccessPolicy};
use std::fs::{File, Metadata};
use std::io::{Seek, SeekFrom};
@@ -41,7 +42,17 @@ pub fn identity_validator(metadata: &Metadata) -> std::io::Result<Vec<u8>> {
}
}
/// One pinned target and its save parent. Retained through the shared operation.
/// Target, save-parent, and root descriptors retained during shared operations.
///
/// Validators encode device/inode and protected metadata, detecting same-content replacements
/// independently of content hashes. Excludes atime so Read does not invalidate its own observation.
/// Saves publish a new temporary inode by rename; updating one hard-link name does not alter
/// other aliases of the old inode.
///
/// Checks named targets and parents before commit, but cannot exclude external replacements between
/// the final check and rename. Pinned descriptors alone do not guarantee linearizability against
/// arbitrary external editors. Failures after parent creation or save begins report `effects_possible`
/// to callers rather than implying no effects.
pub struct CheckedTarget<'a> {
policy: &'a dyn FsAccessPolicy,
root: File,
+12 -2
View File
@@ -1,5 +1,15 @@
//! Ignore rules loaded only through the provider's authorized descriptor opens.
//! Never use GitignoreBuilder::add/build_global: those reopen host pathnames.
//! Ignore rules read only through provider-authorized descriptors.
//!
//! .ignore takes precedence over Git rules; nearer directories win within the same source class.
//! Retains only current ancestor matchers, pruning ignored directories before enumeration or reading
//! their own rules. Checks scope before opening rule sources; configuration discovery never expands
//! content authority.
//!
//! Glob requires an authorized .git marker; Grep applies .gitignore even without one.
//! Glob skips unavailable sources; Grep preserves existing explicit errors. Both skip scope-denied
//! sources before opening. UTF-8, file/line/aggregate byte limit, and cancellation violations are
//! not turned into under-filtered success. Does not follow global/ambient Git configuration or
//! linked-worktree gitdir/commondir pointers; .git files serve only as markers.
use crate::{FsAccessPolicy, FsError};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::io::{BufRead, BufReader, Read};
+7 -1
View File
@@ -5,7 +5,13 @@ use ignore::WalkBuilder;
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult};
/// Execute a bounded glob entirely inside the provider process.
/// Bounded Glob within the provider process.
///
/// Descriptor providers check scope before reading ignore rules and prune denied directories
/// before descent. .ignore takes precedence over Git rules; Git rules require an authorized .git
/// marker. Unavailable rule sources are skipped; byte limit or cancellation violations yield errors.
/// Does not follow ambient/global configuration or linked-worktree management pointers.
/// Separate from configuration discovery in regular path-backed walkers.
pub fn run_glob(
root: &Path,
base: &Path,
+1 -5
View File
@@ -1,8 +1,4 @@
//! Foundational filesystem operation contracts and provider-side search.
//!
//! This crate deliberately has no dependency on Workdir identity, Runtime
//! transport, or LLM Tool implementations. Paths are logical and root-relative;
//! providers supply host roots and access policy.
#![doc = include_str!("../README.md")]
mod checked;
pub use checked::{CheckedTarget, identity_validator};
+11
View File
@@ -473,6 +473,17 @@ fn list_entry_bytes(entry: &ListEntry) -> usize {
entry.path.as_str().len().saturating_add(64)
}
/// Bounded live pagination of direct children.
///
/// Sorts directories first, then logical paths within each group. Cursors are exclusive ordering
/// keys and need not still exist. List applies provider read/enumeration scope, not ignore/dotfile
/// filters. The full scan retains only limit+1 candidates within an aggregate path-byte limit.
/// Enumeration order is unspecified, so a full page does not end scanning. Totals describe current
/// visible entries, including those before the cursor.
///
/// No cross-page snapshot: insertions, removals, renames, and kind changes may omit or duplicate entries.
/// Cancellation and traversal ceilings apply even to excluded entries; resource exhaustion is not
/// converted into silent success.
pub fn run_list(
root: &Path,
request: ListRequest,
+7
View File
@@ -220,6 +220,13 @@ struct GrepParams {
offset: Option<usize>,
}
/// Bounded Grep reading content within provider scope.
///
/// Typed result paths come from reports retained after offset/limit, never reverse-parsed from
/// rendered filenames. Descriptor providers read only authorized ignore sources and apply .gitignore
/// without requiring .git markers. Unlike Glob, unavailable/symlinked ignore opens retain existing
/// explicit errors. Scope-denied sources are skipped before opening; global/ambient configuration
/// is not searched. Search is request-time traversal, not a snapshot.
pub fn run_grep(
root: &Path,
base: PathBuf,
+10 -3
View File
@@ -1,7 +1,14 @@
//! Pure text-operation policy shared by filesystem providers and operation frontends.
//! Pure text operations shared by providers and frontends.
//!
//! This module owns no paths, descriptors, version fences, or I/O. Limits count
//! UTF-8 bytes, not characters, and default to unlimited.
//! Owns argument schemas, JSON decoding, edit conditions, and result sizes, not paths, descriptors,
//! authorization, I/O, or version fences. Providers call within existing consistency boundaries;
//! checked edits must not be decomposed into unchecked Read–Write. Adapters own whole-text versus
//! line-range Read selection, history, digests, and canonical multi-file commits.
//!
//! Edit requires a nonempty old string and a different replacement, which may be empty.
//! Unless replace_all is set, the nonoverlapping match must be unique. Providers set UTF-8 byte limits. Arithmetic overflow and capacity are
//! checked before allocating results. Pure validation failures have no side effects; transport
//! failures after dispatch are not converted into validation failures here.
use schemars::JsonSchema;
use serde::{Deserialize, de::DeserializeOwned};
+10
View File
@@ -0,0 +1,10 @@
# job
A shared data contract for immutable Job requests and structured results across Hosts. Validates inputs, Profile selectors, execution limits, and digests. Each Host adapter owns execution, grants, persistence, state transitions, and cleanup.
## Reference index
- [`JobRequest`] — request identity and input snapshot.
- [`JobLimits`] — execution limits that Hosts can further restrict.
- [`JobResultSubmission`] — results bound to a Job, attempt, and input.
- [`JobOutcome`] — read projection separating Job state from attempt state.
+11 -4
View File
@@ -1,7 +1,4 @@
//! Transport-neutral immutable Job intent and structured-result contract.
//!
//! Hosts own execution, authority, persistence and lifecycle state. This crate
//! deliberately contains no runner, state machine, Runtime or domain grants.
#![doc = include_str!("../README.md")]
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -94,6 +91,11 @@ const fn default_max_attempts() -> u8 {
DEFAULT_MAX_ATTEMPTS
}
/// Immutable execution intent within a Host namespace.
///
/// `fingerprint` identifies the entire request; `input_digest` binds results to the input snapshot.
/// Hosts own same-ID replay and attempt creation rules. Profile selector validation checks syntax
/// only; Hosts separately validate registry existence and requirements and grant domain capabilities.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JobRequest {
@@ -166,6 +168,11 @@ impl JobRequest {
}
}
/// Structured result bound to a Job, attempt, and immutable input.
///
/// Hosts validate bindings and result digests at the acceptance boundary. Structured result
/// acceptance, consumer delivery, and execution resource cleanup are separate facts;
/// Worker termination and final prose do not imply acceptance.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JobResultSubmission {
+5 -24
View File
@@ -1,28 +1,9 @@
# lint-common
## Role
Shared frontmatter splitting and slug validation for Markdown records. Only syntax handling is shared; owning crates retain validation of record-specific required fields and semantics.
`lint-common` contains shared linting primitives for structured project records.
## Main entry points
## Boundaries
Owns:
- reusable frontmatter/slug/path validation helpers
- common lint diagnostic shapes used by higher-level crates
Does not own:
- memory-specific policy (`memory`)
- workflow-specific policy (`workflow`)
- Ticket backend policy (`ticket`)
- product CLI command shape (`yoi`)
## Design notes
Keeping common lint mechanics here avoids duplicating low-level validation while leaving each record type's policy in its owning crate.
## See also
- [`../../docs/design/memory-knowledge.md`](../../docs/design/memory-knowledge.md)
- [`../../docs/development/work-items.md`](../../docs/development/work-items.md)
- [`split_frontmatter`] and [`Frontmatter`] — frontmatter/body boundary
- [`Slug`] and [`is_valid_slug`] — identifier syntax validation
- [`RecordLintError`] — shared syntax errors

Some files were not shown because too many files have changed in this diff Show More