docs: consolidate design documentation and standardize crate English
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user