feat: prepare agen crates for publication
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ。
|
||||
//!
|
||||
//! `AuthRequirement` は scheme が宣言する「この scheme はどんな認証を
|
||||
//! 期待するか」のランタイム記述で、設定ファイルや環境変数などから
|
||||
//! [`super::transport::ResolvedAuth`] を組み立てる責務は呼び出し側にある。
|
||||
//!
|
||||
//! リクエスト毎にトークンが変わり得る認証は [`AuthProvider`] trait を
|
||||
//! 実装し、[`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
|
||||
use super::error::ClientError;
|
||||
|
||||
/// `Scheme::required_auth()` が返す認証要件。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthRequirement {
|
||||
/// 認証を行わない(Ollama など)
|
||||
None,
|
||||
/// `Authorization: Bearer <token>` ヘッダ(token は API key 相当)
|
||||
Bearer,
|
||||
/// `x-api-key: <token>` ヘッダ(Anthropic 形式)
|
||||
XApiKey,
|
||||
/// クエリパラメータ `?<name>=<token>`(Gemini 形式)
|
||||
QueryParam { name: &'static str },
|
||||
/// 複合ヘッダ(呼び出し側が [`AuthProvider`] で解決)
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
||||
///
|
||||
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
||||
/// 必要があるケースで使う。実体は呼び出し側に置き、agen は
|
||||
/// trait を知るだけ。
|
||||
///
|
||||
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
||||
/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら
|
||||
/// 実装側でセットすること。
|
||||
#[async_trait]
|
||||
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
|
||||
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! モデル能力メタデータ
|
||||
//!
|
||||
//! `ModelCapability` はモデルが持つ機能差を表現する。scheme は同じでも
|
||||
//! モデルごとに reasoning 可否や prompt caching 方式が違うため、scheme
|
||||
//! から分離して保持する。
|
||||
//!
|
||||
//! 値の供給経路は 2 通り:
|
||||
//! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル)
|
||||
//! 2. `ModelConfig::capability` での明示 override(未知モデル、または上書き)
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// モデル能力メタデータ
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelCapability {
|
||||
pub tool_calling: ToolCallingSupport,
|
||||
pub structured_output: StructuredOutput,
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<ReasoningSupport>,
|
||||
#[serde(default)]
|
||||
pub vision: bool,
|
||||
pub prompt_caching: CacheStrategy,
|
||||
}
|
||||
|
||||
impl ModelCapability {
|
||||
/// 何もサポートしない安全側デフォルト。未知モデルのフォールバック用。
|
||||
pub const fn minimal() -> Self {
|
||||
Self {
|
||||
tool_calling: ToolCallingSupport::None,
|
||||
structured_output: StructuredOutput::None,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ツール呼び出しサポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallingSupport {
|
||||
/// 非サポート
|
||||
None,
|
||||
/// 1 回のレスポンスで 1 ツールのみ
|
||||
Sequential,
|
||||
/// 1 回のレスポンスで複数ツール並行
|
||||
Parallel,
|
||||
}
|
||||
|
||||
/// Structured output サポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StructuredOutput {
|
||||
None,
|
||||
/// `json_object` モード(スキーマなし JSON 強制)
|
||||
JsonObject,
|
||||
/// JSON Schema 指定で構造化出力
|
||||
JsonSchema,
|
||||
}
|
||||
|
||||
/// Reasoning(extended thinking)サポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReasoningSupport {
|
||||
/// OpenAI 形式: `reasoning.effort` (low/medium/high)
|
||||
Effort,
|
||||
/// Anthropic 形式: `thinking.budget_tokens`
|
||||
BudgetTokens,
|
||||
/// 両対応(内部では共通 `ReasoningControl` として扱い、各 scheme で投影)
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Prompt caching 戦略
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CacheStrategy {
|
||||
/// Anthropic: `cache_control` マーカーを明示挿入
|
||||
Explicit { max_breakpoints: u8 },
|
||||
/// それ以外: サーバ側自動 prefix、または未サポート
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Reasoning 制御(共通型、scheme 側で各社形式に投影)。
|
||||
///
|
||||
/// 文字列は provider-native な effort label、数値は provider-native な
|
||||
/// thinking budget token として扱う。どちらか一方だけを型で表現する。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum ReasoningControl {
|
||||
Effort(ReasoningEffort),
|
||||
BudgetTokens(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReasoningEffort {
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
XHigh,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Minimal => "minimal",
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Other(label) => label.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ReasoningEffort {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"minimal" => Self::Minimal,
|
||||
"low" => Self::Low,
|
||||
"medium" => Self::Medium,
|
||||
"high" => Self::High,
|
||||
"xhigh" => Self::XHigh,
|
||||
_ => Self::Other(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ReasoningEffort {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ReasoningEffort {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer).map(Self::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ReasoningControl, ReasoningEffort};
|
||||
|
||||
#[test]
|
||||
fn reasoning_control_deserializes_effort_labels() {
|
||||
let known: ReasoningControl = serde_json::from_str(r#""xhigh""#).unwrap();
|
||||
assert_eq!(known, ReasoningControl::Effort(ReasoningEffort::XHigh));
|
||||
|
||||
let unknown: ReasoningControl = serde_json::from_str(r#""provider-native""#).unwrap();
|
||||
assert_eq!(
|
||||
unknown,
|
||||
ReasoningControl::Effort(ReasoningEffort::Other("provider-native".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_control_deserializes_signed_budget() {
|
||||
let dynamic: ReasoningControl = serde_json::from_str("-1").unwrap();
|
||||
assert_eq!(dynamic, ReasoningControl::BudgetTokens(-1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! LLMクライアント共通trait定義
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::llm_client::{ClientError, Request, RequestConfig, event::Event};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
|
||||
/// 設定に関する警告
|
||||
///
|
||||
/// プロバイダがサポートしていない設定を使用した場合に返される。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigWarning {
|
||||
/// 設定オプション名
|
||||
pub option_name: &'static str,
|
||||
/// 警告メッセージ
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ConfigWarning {
|
||||
/// 新しい警告を作成
|
||||
pub fn unsupported(option_name: &'static str, provider_name: &str) -> Self {
|
||||
Self {
|
||||
option_name,
|
||||
message: format!(
|
||||
"'{}' is not supported by {} and will be ignored",
|
||||
option_name, provider_name
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigWarning {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}: {}", self.option_name, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>;
|
||||
|
||||
/// LLMクライアントのtrait
|
||||
///
|
||||
/// 各プロバイダはこのtraitを実装し、統一されたインターフェースを提供する。
|
||||
#[async_trait]
|
||||
pub trait LlmClient: Send + Sync {
|
||||
/// ストリーミングリクエストを送信し、Eventストリームを返す
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - リクエスト情報
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Stream)` - イベントストリーム
|
||||
/// * `Err(ClientError)` - エラー
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError>;
|
||||
|
||||
/// Clone this client into a new `Box<dyn LlmClient>`.
|
||||
///
|
||||
/// Used when a second client instance is needed (e.g. for context
|
||||
/// compaction) without access to the original construction parameters.
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient>;
|
||||
|
||||
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - バリデーション対象の設定
|
||||
///
|
||||
/// # Returns
|
||||
/// サポートされていない設定に対する警告のリスト
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
// デフォルト実装: 全ての設定をサポート
|
||||
let _ = config;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Box<dyn LlmClient> {
|
||||
fn clone(&self) -> Self {
|
||||
self.clone_boxed()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Box<dyn LlmClient>` に対する `LlmClient` の実装
|
||||
///
|
||||
/// これにより、動的ディスパッチを使用するクライアントも `Engine` で利用可能になる。
|
||||
#[async_trait]
|
||||
impl LlmClient for Box<dyn LlmClient> {
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
(**self).stream(request).await
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
(**self).clone_boxed()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
(**self).validate_config(config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! LLMクライアントエラー型
|
||||
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
/// LLMクライアントのエラー
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
/// HTTPリクエストエラー
|
||||
Http(reqwest::Error),
|
||||
/// JSONパースエラー
|
||||
Json(serde_json::Error),
|
||||
/// SSEパースエラー
|
||||
Sse(String),
|
||||
/// APIエラー (プロバイダからのエラーレスポンス)
|
||||
Api {
|
||||
status: Option<u16>,
|
||||
code: Option<String>,
|
||||
message: String,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// A request lifecycle phase exceeded its hard timeout.
|
||||
Timeout {
|
||||
phase: &'static str,
|
||||
timeout: Duration,
|
||||
},
|
||||
/// 設定エラー
|
||||
Config(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ClientError::Http(e) => write!(f, "HTTP error: {}", e),
|
||||
ClientError::Json(e) => write!(f, "JSON parse error: {}", e),
|
||||
ClientError::Sse(msg) => write!(f, "SSE parse error: {}", msg),
|
||||
ClientError::Api {
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
write!(f, "API error")?;
|
||||
if let Some(s) = status {
|
||||
write!(f, " (status: {})", s)?;
|
||||
}
|
||||
if let Some(c) = code {
|
||||
write!(f, " [{}]", c)?;
|
||||
}
|
||||
write!(f, ": {}", message)
|
||||
}
|
||||
ClientError::Timeout { phase, timeout } => {
|
||||
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
||||
}
|
||||
ClientError::Config(msg) => write!(f, "Config error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ClientError::Http(e) => Some(e),
|
||||
ClientError::Json(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for ClientError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
ClientError::Http(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ClientError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
ClientError::Json(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientError {
|
||||
pub fn status(&self) -> Option<u16> {
|
||||
match self {
|
||||
ClientError::Api { status, .. } => *status,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
match self {
|
||||
ClientError::Api { retry_after, .. } => *retry_after,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// transient な失敗としてリトライ対象になるかを判定する。
|
||||
///
|
||||
/// 対象:
|
||||
/// - `Api { status }` のうち 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
|
||||
/// - `Http(reqwest::Error)` のうち `is_connect()` または `is_timeout()`
|
||||
/// - `Timeout { .. }` の lifecycle hard timeout
|
||||
///
|
||||
/// それ以外(Json、Sse、Config、上記以外の Api ステータス)は false。
|
||||
/// SSE 読み出し開始後の失敗は呼び出し側で `Sse` として上に流すため、
|
||||
/// ここで対象外にしておけば自動的に弾かれる。
|
||||
pub fn is_retryable(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api {
|
||||
status: Some(code), ..
|
||||
} => matches!(*code, 408 | 425 | 429 | 500 | 502 | 503 | 504 | 529),
|
||||
ClientError::Api { status: None, .. } => false,
|
||||
ClientError::Timeout { .. } => true,
|
||||
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
||||
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn api_err(status: Option<u16>) -> ClientError {
|
||||
ClientError::Api {
|
||||
status,
|
||||
code: None,
|
||||
message: String::new(),
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_status_codes() {
|
||||
for code in [408u16, 425, 429, 500, 502, 503, 504, 529] {
|
||||
assert!(
|
||||
is_retryable(&api_err(Some(code))),
|
||||
"status {code} should be retryable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_retryable_status_codes() {
|
||||
for code in [400u16, 401, 403, 404, 409, 410, 422, 501] {
|
||||
assert!(
|
||||
!is_retryable(&api_err(Some(code))),
|
||||
"status {code} should not be retryable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_without_status_not_retryable() {
|
||||
assert!(!is_retryable(&api_err(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_timeout_is_retryable() {
|
||||
assert!(is_retryable(&ClientError::Timeout {
|
||||
phase: "stream_open",
|
||||
timeout: Duration::from_secs(30),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_sse_config_not_retryable() {
|
||||
let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
|
||||
assert!(!is_retryable(&ClientError::Json(json_err)));
|
||||
assert!(!is_retryable(&ClientError::Sse("boom".into())));
|
||||
assert!(!is_retryable(&ClientError::Config("boom".into())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! LLMクライアント層のイベント型
|
||||
//!
|
||||
//! 各LLMプロバイダからのストリーミングレスポンスを表現するイベント型。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// =============================================================================
|
||||
// Core Event Types (from llm_client layer)
|
||||
// =============================================================================
|
||||
|
||||
/// LLMからのストリーミングイベント
|
||||
///
|
||||
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
|
||||
/// 統一的に処理されます。
|
||||
///
|
||||
/// # イベントの種類
|
||||
///
|
||||
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
|
||||
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
|
||||
///
|
||||
/// # ブロックのライフサイクル
|
||||
///
|
||||
/// テキスト、thinking、ツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
|
||||
/// の順序でイベントが発生します。thinking の round-trip metadata は
|
||||
/// `BlockStop.reasoning` に載ります。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Event {
|
||||
/// ハートビート
|
||||
Ping(PingEvent),
|
||||
/// トークン使用量
|
||||
Usage(UsageEvent),
|
||||
/// ストリームのステータス変化
|
||||
Status(StatusEvent),
|
||||
/// エラー発生
|
||||
Error(ErrorEvent),
|
||||
/// Scheme が生成内容として解釈しない未対応 SSE イベント。
|
||||
///
|
||||
/// stream trace 用の観測イベントであり、timeline / history には反映しない。
|
||||
UnhandledSse(UnhandledSseEvent),
|
||||
|
||||
/// ブロック開始(テキスト、ツール使用等)
|
||||
BlockStart(BlockStart),
|
||||
/// ブロックの差分データ
|
||||
BlockDelta(BlockDelta),
|
||||
/// ブロック正常終了
|
||||
BlockStop(BlockStop),
|
||||
/// ブロック中断
|
||||
BlockAbort(BlockAbort),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Meta Events
|
||||
// =============================================================================
|
||||
|
||||
/// Pingイベント(ハートビート)
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct PingEvent {
|
||||
pub timestamp: Option<u64>,
|
||||
}
|
||||
|
||||
/// 使用量イベント
|
||||
///
|
||||
/// プロバイダから受信した 1 LLM リクエスト分のトークン会計。
|
||||
/// 各 scheme で正規化され、フィールドの意味は全プロバイダ共通:
|
||||
///
|
||||
/// - `input_tokens` は **送信した prompt prefix 全体の占有量**(プロンプト全長)。
|
||||
/// キャッシュヒット分も含まれる。Anthropic は raw API では非キャッシュ分のみを
|
||||
/// `input_tokens` として返すため、`AnthropicScheme::convert_usage` で
|
||||
/// `cache_read + cache_creation` を加算してこの規約に揃えている。
|
||||
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` は上記の内訳で、
|
||||
/// 料金会計用。占有量からは差し引かない。
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct UsageEvent {
|
||||
/// 送信した prompt prefix の総トークン数(占有量、キャッシュ込み)
|
||||
pub input_tokens: Option<u64>,
|
||||
/// このリクエストで生成された出力トークン数
|
||||
pub output_tokens: Option<u64>,
|
||||
/// `input_tokens + output_tokens`
|
||||
pub total_tokens: Option<u64>,
|
||||
/// `input_tokens` のうちキャッシュから読まれた分(割引料金)
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
/// `input_tokens` のうちこのリクエストでキャッシュに書かれた分(割増料金、Anthropic)
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// ステータスイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StatusEvent {
|
||||
pub status: ResponseStatus,
|
||||
}
|
||||
|
||||
/// レスポンスステータス
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ResponseStatus {
|
||||
/// ストリーム開始
|
||||
Started,
|
||||
/// 正常完了
|
||||
Completed,
|
||||
/// キャンセルされた
|
||||
Cancelled,
|
||||
/// エラー発生
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// エラーイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ErrorEvent {
|
||||
pub code: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 未対応 SSE イベントの観測用メタイベント。
|
||||
///
|
||||
/// `data_preview` は provider から受け取った raw SSE data の bounded preview、
|
||||
/// `data_len` は preview 前の raw data byte length。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnhandledSseEvent {
|
||||
pub provider: String,
|
||||
pub event_type: String,
|
||||
pub data_preview: String,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Block Types
|
||||
// =============================================================================
|
||||
|
||||
/// ブロックの種別
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum BlockType {
|
||||
/// テキスト生成
|
||||
Text,
|
||||
/// 思考 (Claude Extended Thinking等)
|
||||
Thinking,
|
||||
/// ツール呼び出し
|
||||
ToolUse,
|
||||
/// ツール結果
|
||||
ToolResult,
|
||||
}
|
||||
|
||||
/// ブロック開始イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStart {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// ブロック固有のメタデータ
|
||||
pub metadata: BlockMetadata,
|
||||
}
|
||||
|
||||
impl BlockStart {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロックのメタデータ
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BlockMetadata {
|
||||
Text,
|
||||
Thinking,
|
||||
ToolUse { id: String, name: String },
|
||||
ToolResult { tool_use_id: String },
|
||||
}
|
||||
|
||||
/// ブロックデルタイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockDelta {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// デルタの内容
|
||||
pub delta: DeltaContent,
|
||||
}
|
||||
|
||||
/// デルタの内容
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DeltaContent {
|
||||
/// テキストデルタ
|
||||
Text(String),
|
||||
/// 思考デルタ
|
||||
Thinking(String),
|
||||
/// ツール引数のJSON部分文字列
|
||||
InputJson(String),
|
||||
}
|
||||
|
||||
impl DeltaContent {
|
||||
/// デルタのブロック種別を取得
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
match self {
|
||||
DeltaContent::Text(_) => BlockType::Text,
|
||||
DeltaContent::Thinking(_) => BlockType::Thinking,
|
||||
DeltaContent::InputJson(_) => BlockType::ToolUse,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロック停止イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStop {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// 停止理由
|
||||
pub stop_reason: Option<StopReason>,
|
||||
/// Thinking block の停止時に確定した reasoning round-trip metadata。
|
||||
///
|
||||
/// `None` の Thinking block は live streaming / trace 用で、history に
|
||||
/// `Item::Reasoning` として永続化しない。`Some` の場合は block lifecycle
|
||||
/// が永続化の authoritative source になる。
|
||||
pub reasoning: Option<ReasoningBlockData>,
|
||||
}
|
||||
|
||||
impl BlockStop {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロック中断イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockAbort {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// 中断理由
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl BlockAbort {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
/// Thinking block stop で確定した reasoning material。
|
||||
///
|
||||
/// `Item::Reasoning` の round-trip に必要な provider material を保持する。
|
||||
/// `text` は deltas から収集した本文を上書きするために使う(metadata-only
|
||||
/// reasoning block や provider completion event で全文が届くケース)。
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ReasoningBlockData {
|
||||
/// scheme 側で観測した item id(OpenAI Responses の `id`)。
|
||||
pub id: Option<String>,
|
||||
/// reasoning 本体テキスト。`None` の場合は block delta 収集結果を使う。
|
||||
pub text: Option<String>,
|
||||
/// summary (OpenAI Responses の `summary_text[]`)。他 scheme は空。
|
||||
pub summary: Vec<String>,
|
||||
/// 暗号化された opaque blob(Anthropic `redacted_thinking.data` /
|
||||
/// OpenAI Responses `encrypted_content`)。
|
||||
pub encrypted_content: Option<String>,
|
||||
/// Anthropic extended thinking signature。round-trip 必須。
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// 停止理由
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum StopReason {
|
||||
/// 自然終了
|
||||
EndTurn,
|
||||
/// 最大トークン数到達
|
||||
MaxTokens,
|
||||
/// ストップシーケンス到達
|
||||
StopSequence,
|
||||
/// ツール使用
|
||||
ToolUse,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Builder / Factory helpers
|
||||
// =============================================================================
|
||||
|
||||
impl Event {
|
||||
/// テキストブロック開始イベントを作成
|
||||
pub fn text_block_start(index: usize) -> Self {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
metadata: BlockMetadata::Text,
|
||||
})
|
||||
}
|
||||
|
||||
/// テキストデルタイベントを作成
|
||||
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
index,
|
||||
delta: DeltaContent::Text(text.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// テキストブロック停止イベントを作成
|
||||
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
|
||||
Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール使用ブロック開始イベントを作成
|
||||
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール引数デルタイベントを作成
|
||||
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
index,
|
||||
delta: DeltaContent::InputJson(json.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール使用ブロック停止イベントを作成
|
||||
pub fn tool_use_stop(index: usize) -> Self {
|
||||
Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
stop_reason: Some(StopReason::ToolUse),
|
||||
reasoning: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 使用量イベントを作成
|
||||
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
|
||||
Event::Usage(UsageEvent {
|
||||
input_tokens: Some(input_tokens),
|
||||
output_tokens: Some(output_tokens),
|
||||
total_tokens: Some(input_tokens + output_tokens),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pingイベントを作成
|
||||
pub fn ping() -> Self {
|
||||
Event::Ping(PingEvent { timestamp: None })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! LLMクライアント層
|
||||
//!
|
||||
//! 各LLMプロバイダと通信し、統一された[`Event`]
|
||||
//! ストリームを出力します。
|
||||
//!
|
||||
//! # サポートするプロバイダ
|
||||
//!
|
||||
//! - Anthropic (Claude)
|
||||
//! - OpenAI (GPT-4, etc.)
|
||||
//! - Google (Gemini)
|
||||
//! - Ollama (ローカルLLM)
|
||||
//!
|
||||
//! # アーキテクチャ
|
||||
//!
|
||||
//! - [`LlmClient`] - プロバイダ共通のtrait
|
||||
//! - `providers`: プロバイダ固有のクライアント実装
|
||||
//! - `scheme`: APIスキーマ(リクエスト/レスポンス変換)
|
||||
|
||||
pub mod auth;
|
||||
pub mod capability;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod types;
|
||||
|
||||
pub mod retry;
|
||||
pub mod scheme;
|
||||
pub mod transport;
|
||||
|
||||
pub use auth::*;
|
||||
pub use capability::*;
|
||||
pub use client::*;
|
||||
pub use error::*;
|
||||
pub use event::*;
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,104 @@
|
||||
//! LLM response stream を開く前の transient error 向けリトライポリシー。
|
||||
//!
|
||||
//! `LlmClient::stream` の open error に対して `is_retryable` を見て
|
||||
//! retry / backoff / cancellation をまとめて管理する。
|
||||
//! SSE 読み出し開始後の失敗は対象外。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
||||
///
|
||||
/// `Default` は agen 全体の固定値を返す。呼び出し側からの上書きが
|
||||
/// 必要になったら拡張する。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryPolicy {
|
||||
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から
|
||||
/// フルジッターで実際の wait を抽選する。
|
||||
pub base: Duration,
|
||||
/// 1 回あたりの wait の上限。
|
||||
pub cap: Duration,
|
||||
/// 試行の合計回数(初回 + リトライ)。`1` ならリトライしない。
|
||||
pub max_attempts: u32,
|
||||
/// 初回送信開始からの累積タイムアウト。これを超える wait は打ち切る。
|
||||
pub total_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for RetryPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: Duration::from_millis(500),
|
||||
cap: Duration::from_secs(10),
|
||||
max_attempts: 4,
|
||||
total_timeout: Duration::from_secs(40),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RetryPolicy {
|
||||
/// `attempt` 回目の失敗(0-indexed)後に待つ時間を返す。
|
||||
/// `Retry-After` で上書きしたい場合は呼び出さず、その値をそのまま使う。
|
||||
pub fn backoff(&self, attempt: u32) -> Duration {
|
||||
let shift = attempt.min(20);
|
||||
let base_nanos = self.base.as_nanos() as u64;
|
||||
let exp_nanos = base_nanos.saturating_mul(1u64 << shift);
|
||||
let cap_nanos = self.cap.as_nanos() as u64;
|
||||
let upper = exp_nanos.min(cap_nanos);
|
||||
Duration::from_nanos(jitter_nanos(upper))
|
||||
}
|
||||
}
|
||||
|
||||
/// `[0, max_nanos]` から擬似乱数的に 1 つ取り出す。`SystemTime` の
|
||||
/// 下位ビットを splitmix64 で攪拌するだけの軽量実装で、暗号的乱数性は
|
||||
/// 持たないがフルジッターのぶつかり回避には十分。
|
||||
fn jitter_nanos(max_nanos: u64) -> u64 {
|
||||
if max_nanos == 0 {
|
||||
return 0;
|
||||
}
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let mut x = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^= x >> 31;
|
||||
x % (max_nanos + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_policy_values() {
|
||||
let p = RetryPolicy::default();
|
||||
assert_eq!(p.base, Duration::from_millis(500));
|
||||
assert_eq!(p.cap, Duration::from_secs(10));
|
||||
assert_eq!(p.max_attempts, 4);
|
||||
assert_eq!(p.total_timeout, Duration::from_secs(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_respects_cap() {
|
||||
let p = RetryPolicy::default();
|
||||
for attempt in 0..30u32 {
|
||||
assert!(
|
||||
p.backoff(attempt) <= p.cap,
|
||||
"attempt {attempt} exceeded cap",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_zero_when_base_zero() {
|
||||
let p = RetryPolicy {
|
||||
base: Duration::ZERO,
|
||||
cap: Duration::from_secs(10),
|
||||
max_attempts: 4,
|
||||
total_timeout: Duration::from_secs(30),
|
||||
};
|
||||
for attempt in 0..5 {
|
||||
assert_eq!(p.backoff(attempt), Duration::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Anthropic scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`claude-*` など)は client construction layer
|
||||
//! の責務。ここでは未知モデルでも「この wire で
|
||||
//! 安全に送れる最小共通項」を返すだけに留める。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability。
|
||||
///
|
||||
/// Ollama の `/v1/messages` 流用を想定して `cache_control` を送らない
|
||||
/// `CacheStrategy::Auto` にする。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
//! Anthropic SSEイベントパース
|
||||
//!
|
||||
//! Anthropic Messages APIのSSEイベントをパースし、統一Event型に変換
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{
|
||||
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, ErrorEvent,
|
||||
Event, PingEvent, ResponseStatus, StatusEvent, UsageEvent,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::AnthropicScheme;
|
||||
use super::scheme_impl::{AnthropicState, PendingThinking};
|
||||
|
||||
/// Anthropic SSEイベントタイプ
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum AnthropicEventType {
|
||||
MessageStart,
|
||||
ContentBlockStart,
|
||||
ContentBlockDelta,
|
||||
ContentBlockStop,
|
||||
MessageDelta,
|
||||
MessageStop,
|
||||
Ping,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl AnthropicEventType {
|
||||
/// イベントタイプ文字列からパース
|
||||
pub(crate) fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"message_start" => Some(Self::MessageStart),
|
||||
"content_block_start" => Some(Self::ContentBlockStart),
|
||||
"content_block_delta" => Some(Self::ContentBlockDelta),
|
||||
"content_block_stop" => Some(Self::ContentBlockStop),
|
||||
"message_delta" => Some(Self::MessageDelta),
|
||||
"message_stop" => Some(Self::MessageStop),
|
||||
"ping" => Some(Self::Ping),
|
||||
"error" => Some(Self::Error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSEイベントのJSON構造
|
||||
// ============================================================================
|
||||
|
||||
/// message_start イベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageStartEvent {
|
||||
pub message: MessageStartMessage,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageStartMessage {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub usage: Option<UsageData>,
|
||||
}
|
||||
|
||||
/// content_block_start イベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockStartEvent {
|
||||
pub index: usize,
|
||||
pub content_block: ContentBlock,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum ContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking {
|
||||
#[serde(default)]
|
||||
thinking: String,
|
||||
/// 非ストリーミングレスポンス由来の初期 signature(通常はストリームでは
|
||||
/// 空 → `signature_delta` で埋まる)。
|
||||
#[serde(default)]
|
||||
signature: Option<String>,
|
||||
},
|
||||
#[serde(rename = "redacted_thinking")]
|
||||
RedactedThinking {
|
||||
/// 暗号化された opaque blob。signature ではなく、まるごと
|
||||
/// `redacted_thinking.data` として送り返す必要がある。
|
||||
#[serde(default)]
|
||||
data: String,
|
||||
},
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// content_block_delta イベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockDeltaEvent {
|
||||
pub index: usize,
|
||||
pub delta: DeltaBlock,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum DeltaBlock {
|
||||
#[serde(rename = "text_delta")]
|
||||
TextDelta { text: String },
|
||||
#[serde(rename = "thinking_delta")]
|
||||
ThinkingDelta { thinking: String },
|
||||
#[serde(rename = "input_json_delta")]
|
||||
InputJsonDelta { partial_json: String },
|
||||
#[serde(rename = "signature_delta")]
|
||||
SignatureDelta { signature: String },
|
||||
}
|
||||
|
||||
/// content_block_stop イベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockStopEvent {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// message_delta イベント
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageDeltaEvent {
|
||||
pub delta: MessageDeltaData,
|
||||
pub usage: Option<UsageData>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageDeltaData {
|
||||
pub stop_reason: Option<String>,
|
||||
pub stop_sequence: Option<String>,
|
||||
}
|
||||
|
||||
/// 使用量データ
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct UsageData {
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// エラーイベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ErrorEventData {
|
||||
pub error: ErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ErrorDetail {
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// イベント変換
|
||||
// ============================================================================
|
||||
|
||||
impl AnthropicScheme {
|
||||
/// SSEイベントをEvent型に変換
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `event_type` - SSEイベントタイプ
|
||||
/// * `data` - イベントデータJSON文字列
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Some(Event))` - 変換成功
|
||||
/// * `Ok(None)` - イベントを無視(unknown event等)
|
||||
/// * `Err(ClientError)` - パースエラー
|
||||
pub(crate) fn parse_event(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
) -> Result<Option<Event>, ClientError> {
|
||||
let Some(event_type) = AnthropicEventType::parse(event_type) else {
|
||||
// Unknown event type, ignore
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match event_type {
|
||||
AnthropicEventType::MessageStart => {
|
||||
let event: MessageStartEvent = serde_json::from_str(data)?;
|
||||
// message_start時にUsageイベントがあれば出力
|
||||
if let Some(usage) = event.message.usage {
|
||||
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
|
||||
}
|
||||
// Statusイベントとして開始を通知
|
||||
Ok(Some(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Started,
|
||||
})))
|
||||
}
|
||||
AnthropicEventType::ContentBlockStart => {
|
||||
let event: ContentBlockStartEvent = serde_json::from_str(data)?;
|
||||
Ok(Some(self.convert_block_start(&event)))
|
||||
}
|
||||
AnthropicEventType::ContentBlockDelta => {
|
||||
let event: ContentBlockDeltaEvent = serde_json::from_str(data)?;
|
||||
Ok(self.convert_block_delta(&event))
|
||||
}
|
||||
AnthropicEventType::ContentBlockStop => {
|
||||
let event: ContentBlockStopEvent = serde_json::from_str(data)?;
|
||||
// Note: BlockStopにはblock_typeが必要だが、AnthropicはStopイベントに含めない
|
||||
// Timeline層がBlockStartを追跡して正しいblock_typeを知る
|
||||
Ok(Some(Event::BlockStop(BlockStop {
|
||||
index: event.index,
|
||||
block_type: BlockType::Text, // Timeline層で上書きされる
|
||||
stop_reason: None,
|
||||
reasoning: None,
|
||||
})))
|
||||
}
|
||||
AnthropicEventType::MessageDelta => {
|
||||
let event: MessageDeltaEvent = serde_json::from_str(data)?;
|
||||
// Usage情報があれば出力
|
||||
if let Some(usage) = event.usage {
|
||||
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
AnthropicEventType::MessageStop => Ok(Some(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}))),
|
||||
AnthropicEventType::Ping => Ok(Some(Event::Ping(PingEvent { timestamp: None }))),
|
||||
AnthropicEventType::Error => {
|
||||
let event: ErrorEventData = serde_json::from_str(data)?;
|
||||
Ok(Some(Event::Error(ErrorEvent {
|
||||
code: Some(event.error.error_type),
|
||||
message: event.error.message,
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_block_start(&self, event: &ContentBlockStartEvent) -> Event {
|
||||
let (block_type, metadata) = match &event.content_block {
|
||||
ContentBlock::Text { .. } => (BlockType::Text, BlockMetadata::Text),
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
|
||||
(BlockType::Thinking, BlockMetadata::Thinking)
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, .. } => (
|
||||
BlockType::ToolUse,
|
||||
BlockMetadata::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
Event::BlockStart(BlockStart {
|
||||
index: event.index,
|
||||
block_type,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_block_delta(&self, event: &ContentBlockDeltaEvent) -> Option<Event> {
|
||||
let delta = match &event.delta {
|
||||
DeltaBlock::TextDelta { text } => DeltaContent::Text(text.clone()),
|
||||
DeltaBlock::ThinkingDelta { thinking } => DeltaContent::Thinking(thinking.clone()),
|
||||
DeltaBlock::InputJsonDelta { partial_json } => {
|
||||
DeltaContent::InputJson(partial_json.clone())
|
||||
}
|
||||
DeltaBlock::SignatureDelta { .. } => {
|
||||
// signature_delta は無視
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(Event::BlockDelta(BlockDelta {
|
||||
index: event.index,
|
||||
delta,
|
||||
}))
|
||||
}
|
||||
|
||||
/// state を持ち回す上位パース。
|
||||
///
|
||||
/// `parse_event` の単発 Event に加えて、以下を行う:
|
||||
/// - `content_block_stop` の `block_type` を直前の Start 値で書き戻す
|
||||
/// - `thinking` / `redacted_thinking` ブロックの本体・signature・data を
|
||||
/// `state.pending_thinking` に蓄積し、`content_block_stop` の Thinking
|
||||
/// BlockStop metadata に載せる
|
||||
/// - `signature_delta` を蓄積(Stream channel には流さず、reasoning metadata
|
||||
/// にだけ反映する)
|
||||
pub(crate) fn parse_with_state(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut AnthropicState,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let Some(parsed_event_type) = AnthropicEventType::parse(event_type) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// signature_delta はストリーム表示には流さず、state にだけ蓄積。
|
||||
// それ以外は parse_event で標準 Event 化する。
|
||||
let mut emitted: Vec<Event> = Vec::new();
|
||||
|
||||
match parsed_event_type {
|
||||
AnthropicEventType::ContentBlockStart => {
|
||||
let raw: ContentBlockStartEvent = serde_json::from_str(data)?;
|
||||
state.current_block_type = Some(match &raw.content_block {
|
||||
ContentBlock::Text { .. } => BlockType::Text,
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
|
||||
BlockType::Thinking
|
||||
}
|
||||
ContentBlock::ToolUse { .. } => BlockType::ToolUse,
|
||||
});
|
||||
match &raw.content_block {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
state.pending_thinking = Some(PendingThinking {
|
||||
text: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
redacted_data: None,
|
||||
});
|
||||
}
|
||||
ContentBlock::RedactedThinking { data: blob } => {
|
||||
state.pending_thinking = Some(PendingThinking {
|
||||
text: String::new(),
|
||||
signature: None,
|
||||
redacted_data: Some(blob.clone()),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
emitted.push(self.convert_block_start(&raw));
|
||||
}
|
||||
AnthropicEventType::ContentBlockDelta => {
|
||||
let raw: ContentBlockDeltaEvent = serde_json::from_str(data)?;
|
||||
match &raw.delta {
|
||||
DeltaBlock::ThinkingDelta { thinking } => {
|
||||
if let Some(pending) = state.pending_thinking.as_mut() {
|
||||
pending.text.push_str(thinking);
|
||||
}
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::Thinking(thinking.clone()),
|
||||
}));
|
||||
}
|
||||
DeltaBlock::SignatureDelta { signature } => {
|
||||
if let Some(pending) = state.pending_thinking.as_mut() {
|
||||
// 通常 1 回しか来ないが、複数 fragment 来ても連結しておく
|
||||
match &mut pending.signature {
|
||||
Some(acc) => acc.push_str(signature),
|
||||
None => pending.signature = Some(signature.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
DeltaBlock::TextDelta { text } => {
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::Text(text.clone()),
|
||||
}));
|
||||
}
|
||||
DeltaBlock::InputJsonDelta { partial_json } => {
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::InputJson(partial_json.clone()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnthropicEventType::ContentBlockStop => {
|
||||
let raw: ContentBlockStopEvent = serde_json::from_str(data)?;
|
||||
let block_type = state.current_block_type.take().unwrap_or(BlockType::Text);
|
||||
let reasoning = if matches!(block_type, BlockType::Thinking) {
|
||||
state
|
||||
.pending_thinking
|
||||
.take()
|
||||
.map(PendingThinking::into_reasoning)
|
||||
} else {
|
||||
state.pending_thinking.take();
|
||||
None
|
||||
};
|
||||
emitted.push(Event::BlockStop(BlockStop {
|
||||
index: raw.index,
|
||||
block_type,
|
||||
stop_reason: None,
|
||||
reasoning,
|
||||
}));
|
||||
}
|
||||
// 残りは state を必要としない。既存 parse_event に委譲。
|
||||
_ => {
|
||||
if let Some(event) = self.parse_event(event_type, data)? {
|
||||
emitted.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(emitted)
|
||||
}
|
||||
|
||||
fn convert_usage(&self, usage: &UsageData) -> UsageEvent {
|
||||
// Anthropic の `input_tokens` は **キャッシュ外** の入力トークンのみで、
|
||||
// プロンプト全長は input_tokens + cache_read + cache_creation。
|
||||
// UsageEvent の `input_tokens` には「占有量(プロンプト全長)」を載せる
|
||||
// 規約に合わせて、ここでキャッシュ分を足し込む。
|
||||
// cache_read_input_tokens / cache_creation_input_tokens は内訳として
|
||||
// 別フィールドに残るので、料金計算側で `input - cache_read - cache_creation`
|
||||
// により非キャッシュ入力分は逆算可能。
|
||||
let raw_input = usage.input_tokens.unwrap_or(0);
|
||||
let cache_read = usage.cache_read_input_tokens.unwrap_or(0);
|
||||
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||
let input_total = raw_input + cache_read + cache_creation;
|
||||
let output = usage.output_tokens.unwrap_or(0);
|
||||
|
||||
UsageEvent {
|
||||
input_tokens: usage.input_tokens.map(|_| input_total),
|
||||
output_tokens: usage.output_tokens,
|
||||
total_tokens: Some(input_total + output),
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_message_start() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data = r#"{"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#;
|
||||
|
||||
let event = scheme.parse_event("message_start", data).unwrap().unwrap();
|
||||
match event {
|
||||
Event::Usage(u) => {
|
||||
// キャッシュなしなので input_total = raw_input = 10
|
||||
assert_eq!(u.input_tokens, Some(10));
|
||||
}
|
||||
_ => panic!("Expected Usage event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_usage_includes_cache_in_input_total() {
|
||||
// Anthropic の input_tokens はキャッシュ外のみで、占有量は
|
||||
// input + cache_read + cache_creation。
|
||||
// UsageEvent.input_tokens は占有量に正規化される。
|
||||
let scheme = AnthropicScheme::new();
|
||||
let usage = UsageData {
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(50),
|
||||
cache_read_input_tokens: Some(800),
|
||||
cache_creation_input_tokens: Some(200),
|
||||
};
|
||||
let event = scheme.convert_usage(&usage);
|
||||
// 100 + 800 + 200 = 1100
|
||||
assert_eq!(event.input_tokens, Some(1100));
|
||||
assert_eq!(event.cache_read_input_tokens, Some(800));
|
||||
assert_eq!(event.cache_creation_input_tokens, Some(200));
|
||||
assert_eq!(event.total_tokens, Some(1150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_content_block_start_text() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data =
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
|
||||
|
||||
let event = scheme
|
||||
.parse_event("content_block_start", data)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
match event {
|
||||
Event::BlockStart(s) => {
|
||||
assert_eq!(s.index, 0);
|
||||
assert_eq!(s.block_type, BlockType::Text);
|
||||
}
|
||||
_ => panic!("Expected BlockStart event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_content_block_delta_text() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
|
||||
|
||||
let event = scheme
|
||||
.parse_event("content_block_delta", data)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
match event {
|
||||
Event::BlockDelta(d) => {
|
||||
assert_eq!(d.index, 0);
|
||||
match d.delta {
|
||||
DeltaContent::Text(t) => assert_eq!(t, "Hello"),
|
||||
_ => panic!("Expected Text delta"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected BlockDelta event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_use_start() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_123","name":"get_weather","input":{}}}"#;
|
||||
|
||||
let event = scheme
|
||||
.parse_event("content_block_start", data)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
match event {
|
||||
Event::BlockStart(s) => {
|
||||
assert_eq!(s.block_type, BlockType::ToolUse);
|
||||
match s.metadata {
|
||||
BlockMetadata::ToolUse { id, name } => {
|
||||
assert_eq!(id, "toolu_123");
|
||||
assert_eq!(name, "get_weather");
|
||||
}
|
||||
_ => panic!("Expected ToolUse metadata"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected BlockStart event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_block_stop_carries_reasoning_with_signature() {
|
||||
// thinking ブロックが完了したら reasoning metadata に text+signature が乗ること
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
let evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(evs[0], Event::BlockStart(_)));
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hello "}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"world"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG-XYZ"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stop_evs.len(), 1);
|
||||
let Event::BlockStop(stop) = &stop_evs[0] else {
|
||||
panic!("expected BlockStop, got {:?}", stop_evs[0]);
|
||||
};
|
||||
let reasoning = stop.reasoning.as_ref().expect("reasoning metadata");
|
||||
assert_eq!(reasoning.text.as_deref(), Some("hello world"));
|
||||
assert_eq!(reasoning.signature.as_deref(), Some("SIG-XYZ"));
|
||||
assert!(reasoning.encrypted_content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_thinking_stop_carries_reasoning_with_data() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"opaque-blob"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stop_evs.len(), 1);
|
||||
let Event::BlockStop(stop) = &stop_evs[0] else {
|
||||
panic!("expected BlockStop");
|
||||
};
|
||||
let reasoning = stop.reasoning.as_ref().expect("reasoning metadata");
|
||||
assert_eq!(reasoning.text.as_deref(), Some(""));
|
||||
assert!(reasoning.signature.is_none());
|
||||
assert_eq!(reasoning.encrypted_content.as_deref(), Some("opaque-blob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_block_stop_has_no_reasoning_metadata() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stop_evs.len(), 1);
|
||||
let Event::BlockStop(stop) = &stop_evs[0] else {
|
||||
panic!("expected BlockStop");
|
||||
};
|
||||
assert!(stop.reasoning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ping() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data = r#"{"type":"ping"}"#;
|
||||
|
||||
let event = scheme.parse_event("ping", data).unwrap().unwrap();
|
||||
match event {
|
||||
Event::Ping(_) => {}
|
||||
_ => panic!("Expected Ping event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Anthropic Messages API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::AnthropicState;
|
||||
|
||||
/// Anthropicスキーマ
|
||||
///
|
||||
/// Anthropic Messages APIのリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnthropicScheme {
|
||||
/// APIバージョン
|
||||
pub api_version: String,
|
||||
/// 細粒度ツールストリーミングを有効にするか
|
||||
pub fine_grained_tool_streaming: bool,
|
||||
}
|
||||
|
||||
impl Default for AnthropicScheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_version: "2023-06-01".to_string(),
|
||||
fine_grained_tool_streaming: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnthropicScheme {
|
||||
/// 新しいスキーマを作成
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 細粒度ツールストリーミングを有効/無効にする
|
||||
pub fn with_fine_grained_tool_streaming(mut self, enabled: bool) -> Self {
|
||||
self.fine_grained_tool_streaming = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
//! `impl Scheme for AnthropicScheme`
|
||||
//!
|
||||
//! Anthropic Messages API の wire 表現に必要な URL・ヘッダ・SSE パース・
|
||||
//! リクエスト body 生成を共通 `Scheme` trait にぶら下げる。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
event::{BlockType, Event, ReasoningBlockData},
|
||||
scheme::Scheme,
|
||||
types::Request,
|
||||
};
|
||||
|
||||
use super::AnthropicScheme;
|
||||
|
||||
/// Anthropic の SSE パースで必要な状態。
|
||||
///
|
||||
/// 1. `content_block_stop` イベントは `block_type` を持たない仕様なので、
|
||||
/// 直前の `content_block_start` で観測した `block_type` を保持して
|
||||
/// `BlockStop` に書き戻す。
|
||||
/// 2. `thinking` ブロック中の `thinking_delta` テキストと `signature_delta`
|
||||
/// 署名、および `redacted_thinking` ブロックの `data` を蓄積し、
|
||||
/// `content_block_stop` の Thinking block metadata として返す
|
||||
/// (round-trip 永続化のため)。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AnthropicState {
|
||||
pub(crate) current_block_type: Option<BlockType>,
|
||||
pub(crate) pending_thinking: Option<PendingThinking>,
|
||||
}
|
||||
|
||||
/// 1 つの `thinking` または `redacted_thinking` content_block の蓄積バッファ。
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PendingThinking {
|
||||
pub(crate) text: String,
|
||||
pub(crate) signature: Option<String>,
|
||||
pub(crate) redacted_data: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingThinking {
|
||||
pub(crate) fn into_reasoning(self) -> ReasoningBlockData {
|
||||
ReasoningBlockData {
|
||||
id: None,
|
||||
text: Some(self.text),
|
||||
summary: Vec::new(),
|
||||
encrypted_content: self.redacted_data,
|
||||
signature: self.signature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scheme for AnthropicScheme {
|
||||
type State = AnthropicState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.anthropic.com"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/v1/messages".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
// Ollama の `/v1/messages` 互換では認証が要らないが、それは
|
||||
// `AuthRef::None` + `build_headers` 側の「ResolvedAuth::None
|
||||
// なら何もしない」分岐で吸収する(`accepts` 判定で弾かれない
|
||||
// よう、現状は XApiKey を要求しつつ、None 側でもパスするよう
|
||||
// にする戦略)。
|
||||
AuthRequirement::XApiKey
|
||||
}
|
||||
|
||||
fn additional_headers(&self) -> Vec<(&'static str, String)> {
|
||||
let mut headers = vec![("anthropic-version", self.api_version.clone())];
|
||||
if self.fine_grained_tool_streaming {
|
||||
headers.push((
|
||||
"anthropic-beta",
|
||||
"fine-grained-tool-streaming-2025-05-14".to_string(),
|
||||
));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&req).expect("AnthropicRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
self.parse_with_state(event_type, data, state)
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Gemini scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は
|
||||
//! client construction layer の責務。ここでは wire の
|
||||
//! 保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability(未知モデル / 未明示モデル用)。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Gemini SSEイベントパース
|
||||
//!
|
||||
//! Google Gemini APIのSSEイベントをパースし、統一Event型に変換
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{BlockMetadata, BlockStart, BlockStop, BlockType, Event, StopReason, UsageEvent},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
// ============================================================================
|
||||
// SSEイベントのJSON構造
|
||||
// ============================================================================
|
||||
|
||||
/// Gemini GenerateContentResponse (ストリーミングチャンク)
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateContentResponse {
|
||||
/// 候補
|
||||
pub candidates: Option<Vec<Candidate>>,
|
||||
/// 使用量メタデータ
|
||||
pub usage_metadata: Option<UsageMetadata>,
|
||||
/// プロンプトフィードバック
|
||||
pub prompt_feedback: Option<PromptFeedback>,
|
||||
/// モデルバージョン
|
||||
pub model_version: Option<String>,
|
||||
}
|
||||
|
||||
/// 候補
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Candidate {
|
||||
/// コンテンツ
|
||||
pub content: Option<CandidateContent>,
|
||||
/// 完了理由
|
||||
pub finish_reason: Option<String>,
|
||||
/// インデックス
|
||||
pub index: Option<usize>,
|
||||
/// 安全性評価
|
||||
pub safety_ratings: Option<Vec<SafetyRating>>,
|
||||
}
|
||||
|
||||
/// 候補コンテンツ
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct CandidateContent {
|
||||
/// パーツ
|
||||
pub parts: Option<Vec<CandidatePart>>,
|
||||
/// ロール
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
/// 候補パーツ
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CandidatePart {
|
||||
/// テキスト
|
||||
pub text: Option<String>,
|
||||
/// 関数呼び出し
|
||||
pub function_call: Option<FunctionCall>,
|
||||
}
|
||||
|
||||
/// 関数呼び出し
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct FunctionCall {
|
||||
/// 関数名
|
||||
pub name: String,
|
||||
/// 引数
|
||||
pub args: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 使用量メタデータ
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UsageMetadata {
|
||||
/// プロンプトトークン数
|
||||
pub prompt_token_count: Option<u64>,
|
||||
/// 候補トークン数
|
||||
pub candidates_token_count: Option<u64>,
|
||||
/// 合計トークン数
|
||||
pub total_token_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// プロンプトフィードバック
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PromptFeedback {
|
||||
/// ブロック理由
|
||||
pub block_reason: Option<String>,
|
||||
/// 安全性評価
|
||||
pub safety_ratings: Option<Vec<SafetyRating>>,
|
||||
}
|
||||
|
||||
/// 安全性評価
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct SafetyRating {
|
||||
/// カテゴリ
|
||||
pub category: Option<String>,
|
||||
/// 確率
|
||||
pub probability: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// イベント変換
|
||||
// ============================================================================
|
||||
|
||||
impl GeminiScheme {
|
||||
/// SSEデータをEvent型に変換
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - SSEイベントデータJSON文字列
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Some(Vec<Event>))` - 変換成功
|
||||
/// * `Ok(None)` - イベントを無視
|
||||
/// * `Err(ClientError)` - パースエラー
|
||||
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
// データが空または無効な場合はスキップ
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response: GenerateContentResponse =
|
||||
serde_json::from_str(data).map_err(|e| ClientError::Api {
|
||||
status: None,
|
||||
code: Some("parse_error".to_string()),
|
||||
message: format!("Failed to parse Gemini SSE data: {} -> {}", e, data),
|
||||
retry_after: None,
|
||||
})?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
// 使用量メタデータ
|
||||
if let Some(usage) = response.usage_metadata {
|
||||
events.push(self.convert_usage(&usage));
|
||||
}
|
||||
|
||||
// 候補を処理
|
||||
if let Some(candidates) = response.candidates {
|
||||
for candidate in candidates {
|
||||
let candidate_index = candidate.index.unwrap_or(0);
|
||||
|
||||
if let Some(content) = candidate.content {
|
||||
if let Some(parts) = content.parts {
|
||||
for (part_index, part) in parts.iter().enumerate() {
|
||||
// テキストデルタ
|
||||
if let Some(text) = &part.text {
|
||||
if !text.is_empty() {
|
||||
// Geminiは明示的なBlockStartを送らないため、
|
||||
// TextDeltaを直接送る(Timelineが暗黙的に開始を処理)
|
||||
events.push(Event::text_delta(part_index, text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// 関数呼び出し
|
||||
if let Some(function_call) = &part.function_call {
|
||||
// 関数呼び出しの開始
|
||||
// Geminiでは関数呼び出しは一度に送られることが多い
|
||||
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
|
||||
|
||||
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
|
||||
let function_id = format!("call_{}", function_call.name);
|
||||
|
||||
events.push(Event::BlockStart(BlockStart {
|
||||
index: candidate_index * 10 + part_index, // 複合インデックス
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: function_id,
|
||||
name: function_call.name.clone(),
|
||||
},
|
||||
}));
|
||||
|
||||
// 引数がある場合はデルタとして送る
|
||||
if let Some(args) = &function_call.args {
|
||||
let args_str = serde_json::to_string(args).unwrap_or_default();
|
||||
if !args_str.is_empty() && args_str != "null" {
|
||||
events.push(Event::tool_input_delta(
|
||||
candidate_index * 10 + part_index,
|
||||
args_str,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 完了理由
|
||||
if let Some(finish_reason) = candidate.finish_reason {
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"STOP" => Some(StopReason::EndTurn),
|
||||
"MAX_TOKENS" => Some(StopReason::MaxTokens),
|
||||
"SAFETY" | "RECITATION" | "OTHER" => Some(StopReason::EndTurn),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// テキストブロックの停止
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index: candidate_index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(events))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_usage(&self, usage: &UsageMetadata) -> Event {
|
||||
Event::Usage(UsageEvent {
|
||||
input_tokens: usage.prompt_token_count,
|
||||
output_tokens: usage.candidates_token_count,
|
||||
total_tokens: usage.total_token_count,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::event::DeltaContent;
|
||||
|
||||
#[test]
|
||||
fn test_parse_text_response() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data =
|
||||
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
assert_eq!(delta.index, 0);
|
||||
if let DeltaContent::Text(text) = &delta.delta {
|
||||
assert_eq!(text, "Hello");
|
||||
} else {
|
||||
panic!("Expected text delta");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected BlockDelta");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_metadata() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// Usageイベントが含まれるはず
|
||||
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
|
||||
assert!(usage_event.is_some());
|
||||
|
||||
if let Event::Usage(usage) = usage_event.unwrap() {
|
||||
assert_eq!(usage.input_tokens, Some(10));
|
||||
assert_eq!(usage.output_tokens, Some(5));
|
||||
assert_eq!(usage.total_tokens, Some(15));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_function_call() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// BlockStartイベントがあるはず
|
||||
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
|
||||
assert!(start_event.is_some());
|
||||
|
||||
if let Event::BlockStart(start) = start_event.unwrap() {
|
||||
assert_eq!(start.block_type, BlockType::ToolUse);
|
||||
if let BlockMetadata::ToolUse { id: _, name } = &start.metadata {
|
||||
assert_eq!(name, "get_weather");
|
||||
} else {
|
||||
panic!("Expected ToolUse metadata");
|
||||
}
|
||||
}
|
||||
|
||||
// 引数デルタもあるはず
|
||||
let delta_event = events.iter().find(|e| {
|
||||
if let Event::BlockDelta(d) = e {
|
||||
matches!(d.delta, DeltaContent::InputJson(_))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
assert!(delta_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_finish_reason() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// BlockStopイベントがあるはず
|
||||
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
|
||||
assert!(stop_event.is_some());
|
||||
|
||||
if let Event::BlockStop(stop) = stop_event.unwrap() {
|
||||
assert_eq!(stop.stop_reason, Some(StopReason::EndTurn));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_data() {
|
||||
let scheme = GeminiScheme::new();
|
||||
assert!(scheme.parse_event("").unwrap().is_none());
|
||||
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Google Gemini API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// Geminiスキーマ
|
||||
///
|
||||
/// Google Gemini APIのリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GeminiScheme {
|
||||
/// ストリーミング関数呼び出し引数を有効にするか
|
||||
pub stream_function_call_arguments: bool,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// 新しいスキーマを作成
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// ストリーミング関数呼び出し引数を有効/無効にする
|
||||
pub fn with_stream_function_call_arguments(mut self, enabled: bool) -> Self {
|
||||
self.stream_function_call_arguments = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
//! Gemini Request Builder
|
||||
//!
|
||||
//! Converts Open Responses native Item model to Google Gemini API format.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
||||
};
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
/// Gemini API request body
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiRequest {
|
||||
/// Contents (conversation history)
|
||||
pub contents: Vec<GeminiContent>,
|
||||
/// System instruction
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_instruction: Option<GeminiContent>,
|
||||
/// Tool definitions
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<GeminiTool>,
|
||||
/// Tool config
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_config: Option<GeminiToolConfig>,
|
||||
/// Generation config
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generation_config: Option<GeminiGenerationConfig>,
|
||||
}
|
||||
|
||||
/// Gemini content
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiContent {
|
||||
/// Role
|
||||
pub role: String,
|
||||
/// Parts
|
||||
pub parts: Vec<GeminiPart>,
|
||||
}
|
||||
|
||||
/// Gemini part
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum GeminiPart {
|
||||
/// Text part
|
||||
Text { text: String },
|
||||
/// Function call part
|
||||
FunctionCall {
|
||||
#[serde(rename = "functionCall")]
|
||||
function_call: GeminiFunctionCall,
|
||||
},
|
||||
/// Function response part
|
||||
FunctionResponse {
|
||||
#[serde(rename = "functionResponse")]
|
||||
function_response: GeminiFunctionResponse,
|
||||
},
|
||||
}
|
||||
|
||||
/// Gemini function call
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionCall {
|
||||
pub name: String,
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
/// Gemini function response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionResponse {
|
||||
pub name: String,
|
||||
pub response: GeminiFunctionResponseContent,
|
||||
}
|
||||
|
||||
/// Gemini function response content
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionResponseContent {
|
||||
pub name: String,
|
||||
pub content: Value,
|
||||
}
|
||||
|
||||
/// Gemini tool definition
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiTool {
|
||||
/// Function declarations
|
||||
pub function_declarations: Vec<GeminiFunctionDeclaration>,
|
||||
}
|
||||
|
||||
/// Gemini function declaration
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionDeclaration {
|
||||
/// Function name
|
||||
pub name: String,
|
||||
/// Description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Parameter schema
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Gemini tool config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiToolConfig {
|
||||
/// Function calling config
|
||||
pub function_calling_config: GeminiFunctionCallingConfig,
|
||||
}
|
||||
|
||||
/// Gemini function calling config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiFunctionCallingConfig {
|
||||
/// Mode: AUTO, ANY, NONE
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
/// Enable streaming function call arguments
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_function_call_arguments: Option<bool>,
|
||||
}
|
||||
|
||||
/// Gemini generation config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiGenerationConfig {
|
||||
/// Max output tokens
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
/// Temperature
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// Top P
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// Top K
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_k: Option<u32>,
|
||||
/// Stop sequences
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// Thinking / reasoning 設定(Gemini 2.5 以降)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_config: Option<GeminiThinkingConfig>,
|
||||
}
|
||||
|
||||
/// Gemini thinking config (gemini-2.5 以降)
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiThinkingConfig {
|
||||
/// Token budget for thinking. `-1` means dynamic.
|
||||
pub thinking_budget: i32,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// Build Gemini request from Request
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> GeminiRequest {
|
||||
let contents = self.convert_items_to_contents(&request.items);
|
||||
|
||||
// System prompt
|
||||
let system_instruction = request.system_prompt.as_ref().map(|s| GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: vec![GeminiPart::Text { text: s.clone() }],
|
||||
});
|
||||
|
||||
// Tools
|
||||
let tools = if request.tools.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![GeminiTool {
|
||||
function_declarations: request.tools.iter().map(|t| self.convert_tool(t)).collect(),
|
||||
}]
|
||||
};
|
||||
|
||||
// Tool config
|
||||
let tool_config = if !request.tools.is_empty() {
|
||||
Some(GeminiToolConfig {
|
||||
function_calling_config: GeminiFunctionCallingConfig {
|
||||
mode: Some("AUTO".to_string()),
|
||||
stream_function_call_arguments: if self.stream_function_call_arguments {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Reasoning の投影: capability が BudgetTokens / Both をサポートし、
|
||||
// request 側で budget_tokens が指定されているときだけ thinking_config を付ける。
|
||||
let supports_budget = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
|
||||
);
|
||||
let thinking_config = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_budget)
|
||||
.and_then(|rc| match rc {
|
||||
ReasoningControl::BudgetTokens(budget) => Some(GeminiThinkingConfig {
|
||||
thinking_budget: *budget,
|
||||
}),
|
||||
ReasoningControl::Effort(_) => None,
|
||||
});
|
||||
|
||||
// Generation config
|
||||
let generation_config = Some(GeminiGenerationConfig {
|
||||
max_output_tokens: request.config.max_tokens,
|
||||
temperature: request.config.temperature,
|
||||
top_p: request.config.top_p,
|
||||
top_k: request.config.top_k,
|
||||
stop_sequences: request.config.stop_sequences.clone(),
|
||||
thinking_config,
|
||||
});
|
||||
|
||||
GeminiRequest {
|
||||
contents,
|
||||
system_instruction,
|
||||
tools,
|
||||
tool_config,
|
||||
generation_config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Open Responses Items to Gemini Contents
|
||||
///
|
||||
/// Gemini uses:
|
||||
/// - role "user" for user messages and function responses
|
||||
/// - role "model" for assistant messages and function calls
|
||||
fn convert_items_to_contents(&self, items: &[Item]) -> Vec<GeminiContent> {
|
||||
let mut contents = Vec::new();
|
||||
let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending parts
|
||||
self.flush_pending_parts(
|
||||
&mut contents,
|
||||
&mut pending_model_parts,
|
||||
&mut pending_user_parts,
|
||||
);
|
||||
|
||||
let gemini_role = match role {
|
||||
Role::User | Role::System => "user",
|
||||
Role::Assistant => "model",
|
||||
};
|
||||
|
||||
let parts: Vec<GeminiPart> = content
|
||||
.iter()
|
||||
.map(|p| GeminiPart::Text {
|
||||
text: p.as_text().to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
contents.push(GeminiContent {
|
||||
role: gemini_role.to_string(),
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
} => {
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(&mut pending_user_parts),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse arguments (normalize non-object / legacy "null" payloads to {})
|
||||
let args = parse_tool_arguments(arguments);
|
||||
|
||||
pending_model_parts.push(GeminiPart::FunctionCall {
|
||||
function_call: GeminiFunctionCall {
|
||||
name: name.clone(),
|
||||
args,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
// Flush pending model parts first
|
||||
if !pending_model_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "model".to_string(),
|
||||
parts: std::mem::take(&mut pending_model_parts),
|
||||
});
|
||||
}
|
||||
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
pending_user_parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponse {
|
||||
name: call_id.clone(),
|
||||
response: GeminiFunctionResponseContent {
|
||||
name: call_id.clone(),
|
||||
content: Value::String(text),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::Reasoning { text, .. } => {
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(&mut pending_user_parts),
|
||||
});
|
||||
}
|
||||
|
||||
// Reasoning is treated as model text in Gemini
|
||||
pending_model_parts.push(GeminiPart::Text { text: text.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending parts
|
||||
self.flush_pending_parts(
|
||||
&mut contents,
|
||||
&mut pending_model_parts,
|
||||
&mut pending_user_parts,
|
||||
);
|
||||
|
||||
contents
|
||||
}
|
||||
|
||||
fn flush_pending_parts(
|
||||
&self,
|
||||
contents: &mut Vec<GeminiContent>,
|
||||
pending_model_parts: &mut Vec<GeminiPart>,
|
||||
pending_user_parts: &mut Vec<GeminiPart>,
|
||||
) {
|
||||
if !pending_model_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "model".to_string(),
|
||||
parts: std::mem::take(pending_model_parts),
|
||||
});
|
||||
}
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(pending_user_parts),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tool(&self, tool: &ToolDefinition) -> GeminiFunctionDeclaration {
|
||||
GeminiFunctionDeclaration {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_budget_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::BudgetTokens),
|
||||
..cap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_simple_request() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.system("You are a helpful assistant.")
|
||||
.user("Hello!");
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert!(gemini_req.system_instruction.is_some());
|
||||
assert_eq!(gemini_req.contents.len(), 1);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_with_tool() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new().user("What's the weather?").tool(
|
||||
ToolDefinition::new("get_weather")
|
||||
.description("Get current weather")
|
||||
.input_schema(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": { "type": "string" }
|
||||
},
|
||||
"required": ["location"]
|
||||
})),
|
||||
);
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.tools.len(), 1);
|
||||
assert_eq!(gemini_req.tools[0].function_declarations.len(), 1);
|
||||
assert_eq!(
|
||||
gemini_req.tools[0].function_declarations[0].name,
|
||||
"get_weather"
|
||||
);
|
||||
assert!(gemini_req.tool_config.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_role_is_model() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new().user("Hello").assistant("Hi there!");
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.contents.len(), 2);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_and_result() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.user("What's the weather?")
|
||||
.item(Item::tool_call(
|
||||
"call_123",
|
||||
"get_weather",
|
||||
r#"{"city":"Tokyo"}"#,
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.contents.len(), 3);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
assert_eq!(gemini_req.contents[2].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_budget_projected_when_supported() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let mut request = Request::new().user("think");
|
||||
request.config.reasoning = Some(ReasoningControl::BudgetTokens(-1));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap_budget_reasoning());
|
||||
let config = gemini_req.generation_config.expect("generation config");
|
||||
let thinking = config.thinking_config.expect("thinking config");
|
||||
|
||||
assert_eq!(thinking.thinking_budget, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_reasoning_not_projected_to_gemini() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let mut request = Request::new().user("think");
|
||||
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Medium));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap_budget_reasoning());
|
||||
let config = gemini_req.generation_config.expect("generation config");
|
||||
|
||||
assert!(config.thinking_config.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! `impl Scheme for GeminiScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme,
|
||||
types::Request,
|
||||
};
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
impl Scheme for GeminiScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
}
|
||||
|
||||
fn path(&self, model_id: &str) -> String {
|
||||
format!("/v1beta/models/{model_id}:streamGenerateContent?alt=sse")
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::QueryParam { name: "key" }
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
_model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(request, capability);
|
||||
serde_json::to_value(&req).expect("GeminiRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! APIスキーマ定義
|
||||
//!
|
||||
//! 各APIスキーマごとの変換ロジック
|
||||
//! - リクエスト変換: Request → プロバイダ固有JSON
|
||||
//! - レスポンス変換: SSEイベント → Event
|
||||
//!
|
||||
//! [`Scheme`] trait により `HttpTransport<S>` から scheme 固有の差分
|
||||
//! (パス、ヘッダ、認証要件、body 生成、SSE パース)をすべて委譲する。
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod gemini;
|
||||
pub mod openai_chat;
|
||||
pub mod openai_responses;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::auth::AuthRequirement;
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::ConfigWarning;
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::types::{Request, RequestConfig};
|
||||
|
||||
/// wire scheme の抽象。各プロバイダの API 仕様ごとに 1 つ実装する。
|
||||
///
|
||||
/// `HttpTransport<S: Scheme>` が URL 組立・認証ヘッダ挿入・SSE パース
|
||||
/// のループを担い、`Scheme` 実装は各仕様固有の差分のみ提供する。
|
||||
///
|
||||
/// # 状態
|
||||
///
|
||||
/// SSE パースでフレーム間に状態を保つ必要がある scheme(Anthropic の
|
||||
/// `BlockStop` に `block_type` が載らない仕様の補完など)は
|
||||
/// [`Scheme::State`] に中間状態を表す型を置く。
|
||||
/// 状態を持たない scheme は `type State = ()` とする。
|
||||
pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
/// SSE パースのフレーム間で共有する状態。`HttpTransport` が
|
||||
/// ストリーム開始時に `Default::default()` を一度だけ作り、
|
||||
/// フレームごとに `&mut` で渡す。
|
||||
type State: Default + Send + 'static;
|
||||
|
||||
/// scheme のベース URL(`ModelConfig::base_url` 未指定時のデフォルト)
|
||||
fn default_base_url(&self) -> &'static str;
|
||||
|
||||
/// リクエスト先の相対パス。Gemini のようにモデル名をパスに埋め込む
|
||||
/// プロバイダもあるため、モデル ID を受け取る。
|
||||
fn path(&self, model_id: &str) -> String;
|
||||
|
||||
/// この scheme が要求する認証形式。呼び出し側は client 構築時に
|
||||
/// 設定された認証情報と照合する。
|
||||
fn required_auth(&self) -> AuthRequirement;
|
||||
|
||||
/// `Content-Type` 以外の追加ヘッダ。`anthropic-version` / `anthropic-beta` 等。
|
||||
fn additional_headers(&self) -> Vec<(&'static str, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// リクエスト body を生成する。`capability` は `CacheStrategy` や
|
||||
/// `ReasoningSupport` を参照して scheme 側の挙動を分岐させるため
|
||||
/// に渡される。
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value;
|
||||
|
||||
/// SSE イベント 1 件を 0 個以上の [`Event`] に変換する。
|
||||
///
|
||||
/// `event_type` は SSE フレームの `event:` フィールド、`data` は
|
||||
/// `data:` フィールド。`[DONE]` 等の終端マーカーは実装側で判定する。
|
||||
/// `state` はストリーム単位で共有される可変状態。
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError>;
|
||||
|
||||
/// scheme 既定の capability。モデル ID に関係なく、この wire で
|
||||
/// 安全に送れる最小共通項を返す。既知モデル ID の能力テーブルは
|
||||
/// 高レベルの client 構築層の責務で、scheme はここには関与しない。
|
||||
fn default_capability(&self) -> ModelCapability;
|
||||
|
||||
/// scheme 側でサポートしていない `RequestConfig` フィールドを
|
||||
/// 警告として返す(例: OpenAI Chat は `top_k` 非対応)。
|
||||
/// デフォルトは空 Vec。
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let _ = config;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! OpenAI Chat Completions scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gpt-5` 系など)は client construction layer
|
||||
//! の責務。ここでは wire の保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等)
|
||||
/// で未知モデル ID を受けたときのフォールバックに使う。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! OpenAI SSEイベントパース
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{Event, StopReason, UsageEvent},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
/// OpenAI Streaming Chat Response Chunk
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChatCompletionChunk {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<ChunkChoice>,
|
||||
pub usage: Option<ChunkUsage>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkChoice {
|
||||
pub index: usize,
|
||||
pub delta: ChunkDelta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkDelta {
|
||||
pub role: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<ChunkToolCall>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkToolCall {
|
||||
pub index: usize,
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: Option<String>,
|
||||
pub function: Option<ChunkFunction>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkFunction {
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkUsage {
|
||||
pub prompt_tokens: u64,
|
||||
pub completion_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// SSEデータのパースとEventへの変換
|
||||
///
|
||||
/// OpenAI APIはBlockStartイベントを明示的に送信しない。
|
||||
/// Timeline層が暗黙的なBlockStartを処理する。
|
||||
pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
if data == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let chunk: ChatCompletionChunk =
|
||||
serde_json::from_str(data).map_err(|e| ClientError::Api {
|
||||
status: None,
|
||||
code: Some("parse_error".to_string()),
|
||||
message: format!("Failed to parse SSE data: {} -> {}", e, data),
|
||||
retry_after: None,
|
||||
})?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Usage handling
|
||||
if let Some(usage) = chunk.usage {
|
||||
events.push(Event::Usage(UsageEvent {
|
||||
input_tokens: Some(usage.prompt_tokens),
|
||||
output_tokens: Some(usage.completion_tokens),
|
||||
total_tokens: Some(usage.total_tokens),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
}));
|
||||
}
|
||||
|
||||
for choice in chunk.choices {
|
||||
// Text Content Delta
|
||||
if let Some(content) = choice.delta.content {
|
||||
// OpenAI APIはBlockStartを送らないため、デルタのみを発行
|
||||
// Timeline層が暗黙的なBlockStartを処理する
|
||||
events.push(Event::text_delta(choice.index, content));
|
||||
}
|
||||
|
||||
// Tool Call Delta
|
||||
if let Some(tool_calls) = choice.delta.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
// Start of tool call (has ID)
|
||||
if let Some(id) = tool_call.id {
|
||||
let name = tool_call
|
||||
.function
|
||||
.as_ref()
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_default();
|
||||
events.push(Event::tool_use_start(tool_call.index, id, name));
|
||||
}
|
||||
|
||||
// Arguments delta
|
||||
if let Some(function) = tool_call.function {
|
||||
if let Some(args) = function.arguments {
|
||||
if !args.is_empty() {
|
||||
events.push(Event::tool_input_delta(tool_call.index, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish Reason
|
||||
if let Some(finish_reason) = choice.finish_reason {
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"stop" => Some(StopReason::EndTurn),
|
||||
"length" => Some(StopReason::MaxTokens),
|
||||
"tool_calls" | "function_call" => Some(StopReason::ToolUse),
|
||||
_ => Some(StopReason::EndTurn),
|
||||
};
|
||||
|
||||
let is_tool_finish =
|
||||
finish_reason == "tool_calls" || finish_reason == "function_call";
|
||||
|
||||
if is_tool_finish {
|
||||
// ツール呼び出し終了
|
||||
// Note: OpenAIはどのツールが終了したか明示しないため、
|
||||
// Timeline層で適切に処理する必要がある
|
||||
} else {
|
||||
// テキスト終了
|
||||
events.push(Event::text_block_stop(choice.index, stop_reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::event::DeltaContent;
|
||||
|
||||
#[test]
|
||||
fn test_parse_text_delta() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
// OpenAIはBlockStartを発行しないため、デルタのみ
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
assert_eq!(delta.index, 0);
|
||||
if let DeltaContent::Text(text) = &delta.delta {
|
||||
assert_eq!(text, "Hello");
|
||||
} else {
|
||||
panic!("Expected text delta");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected BlockDelta");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_call() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
// Start of tool call
|
||||
let data_start = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data_start).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
if let Event::BlockStart(start) = &events[0] {
|
||||
assert_eq!(start.index, 0);
|
||||
if let crate::llm_client::event::BlockMetadata::ToolUse { id, name } = &start.metadata {
|
||||
assert_eq!(id, "call_abc");
|
||||
assert_eq!(name, "get_weather");
|
||||
} else {
|
||||
panic!("Expected ToolUse metadata");
|
||||
}
|
||||
}
|
||||
|
||||
// Tool arguments delta
|
||||
let data_arg = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}}"}}]},"finish_reason":null}]}"#;
|
||||
let events = scheme.parse_event(data_arg).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
if let DeltaContent::InputJson(json) = &delta.delta {
|
||||
assert_eq!(json, "{}}");
|
||||
} else {
|
||||
panic!("Expected input json delta");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! OpenAI Chat Completions API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
pub(crate) mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// OpenAIスキーマ
|
||||
///
|
||||
/// OpenAI Chat Completions API (および互換API) のリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OpenAIScheme {
|
||||
/// モデル名 (リクエスト時に指定されるが、デフォルト値として保持も可能)
|
||||
pub model: Option<String>,
|
||||
/// レガシーなmax_tokensを使用するか (Ollama互換用)
|
||||
pub use_legacy_max_tokens: bool,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// 新しいスキーマを作成
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// レガシーなmax_tokensを使用するか設定
|
||||
pub fn with_legacy_max_tokens(mut self, use_legacy: bool) -> Self {
|
||||
self.use_legacy_max_tokens = use_legacy;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
//! OpenAI Request Builder
|
||||
//!
|
||||
//! Converts Open Responses native Item model to OpenAI Chat Completions API format.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
|
||||
},
|
||||
tool::Attachment,
|
||||
};
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
/// OpenAI API request body
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIRequest {
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_completion_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>, // Legacy field for compatibility (e.g. Ollama)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop: Vec<String>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
pub messages: Vec<OpenAIMessage>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<OpenAITool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<String>,
|
||||
/// Reasoning effort(o1 / o3 / o4 / gpt-5 系で有効)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct StreamOptions {
|
||||
pub include_usage: bool,
|
||||
}
|
||||
|
||||
/// OpenAI message
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIMessage {
|
||||
pub role: String,
|
||||
pub content: Option<OpenAIContent>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tool_calls: Vec<OpenAIToolCall>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI content
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum OpenAIContent {
|
||||
Text(String),
|
||||
Parts(Vec<OpenAIContentPart>),
|
||||
}
|
||||
|
||||
/// OpenAI content part
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum OpenAIContentPart {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image_url")]
|
||||
ImageUrl { image_url: ImageUrl },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ImageUrl {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// OpenAI tool definition
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAITool {
|
||||
pub r#type: String,
|
||||
pub function: OpenAIToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolFunction {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// OpenAI tool call in message
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolCall {
|
||||
pub id: String,
|
||||
pub r#type: String,
|
||||
pub function: OpenAIToolCallFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// Build OpenAI request from Request
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> OpenAIRequest {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// Add system message if present
|
||||
if let Some(system) = &request.system_prompt {
|
||||
messages.push(OpenAIMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OpenAIContent::Text(system.clone())),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert items to messages
|
||||
messages.extend(self.convert_items_to_messages(&request.items, capability.vision));
|
||||
|
||||
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
|
||||
|
||||
let (max_tokens, max_completion_tokens) = if self.use_legacy_max_tokens {
|
||||
(request.config.max_tokens, None)
|
||||
} else {
|
||||
(None, request.config.max_tokens)
|
||||
};
|
||||
|
||||
// Reasoning の投影: capability が Effort / Both をサポートし、
|
||||
// request 側で effort が指定されているときだけ reasoning_effort を付ける。
|
||||
let supports_effort = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
|
||||
);
|
||||
let reasoning_effort = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_effort)
|
||||
.and_then(|rc| match rc {
|
||||
ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
|
||||
ReasoningControl::BudgetTokens(_) => None,
|
||||
});
|
||||
|
||||
OpenAIRequest {
|
||||
model: model.to_string(),
|
||||
max_completion_tokens,
|
||||
max_tokens,
|
||||
temperature: request.config.temperature,
|
||||
top_p: request.config.top_p,
|
||||
stop: request.config.stop_sequences.clone(),
|
||||
stream: true,
|
||||
stream_options: Some(StreamOptions {
|
||||
include_usage: true,
|
||||
}),
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: None,
|
||||
reasoning_effort,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Open Responses Items to OpenAI Messages
|
||||
///
|
||||
/// OpenAI uses a message-based model where:
|
||||
/// - User messages have role "user"
|
||||
/// - Assistant messages have role "assistant"
|
||||
/// - Tool calls are within assistant messages as tool_calls array
|
||||
/// - Tool results have role "tool" with tool_call_id
|
||||
fn flush_pending_tool_result_images(
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
pending_images: &mut Vec<OpenAIContentPart>,
|
||||
) {
|
||||
if !pending_images.is_empty() {
|
||||
messages.push(OpenAIMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(OpenAIContent::Parts(std::mem::take(pending_images))),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_items_to_messages(
|
||||
&self,
|
||||
items: &[Item],
|
||||
supports_images: bool,
|
||||
) -> Vec<OpenAIMessage> {
|
||||
let mut messages = Vec::new();
|
||||
let mut pending_tool_calls: Vec<OpenAIToolCall> = Vec::new();
|
||||
let mut pending_assistant_text: Option<String> = None;
|
||||
let mut pending_tool_result_images: Vec<OpenAIContentPart> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
if !matches!(item, Item::ToolResult { .. }) {
|
||||
Self::flush_pending_tool_result_images(
|
||||
&mut messages,
|
||||
&mut pending_tool_result_images,
|
||||
);
|
||||
}
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending tool calls
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
|
||||
let openai_role = match role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
};
|
||||
let message_content = OpenAIContent::Text(
|
||||
content
|
||||
.iter()
|
||||
.map(ContentPart::as_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
);
|
||||
|
||||
messages.push(OpenAIMessage {
|
||||
role: openai_role.to_string(),
|
||||
content: Some(message_content),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
// Normalize non-object / legacy "null" payloads to "{}" so
|
||||
// OpenAI gets a valid JSON object string.
|
||||
let normalized_args = parse_tool_arguments(arguments).to_string();
|
||||
pending_tool_calls.push(OpenAIToolCall {
|
||||
id: call_id.clone(),
|
||||
r#type: "function".to_string(),
|
||||
function: OpenAIToolCallFunction {
|
||||
name: name.clone(),
|
||||
arguments: normalized_args,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
..
|
||||
} => {
|
||||
// OpenAI requires every parallel tool result before a new user message.
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
|
||||
let mut text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
if supports_images {
|
||||
pending_tool_result_images.extend(attachments.iter().map(|attachment| {
|
||||
let Attachment::Image(image) = attachment;
|
||||
OpenAIContentPart::ImageUrl {
|
||||
image_url: ImageUrl {
|
||||
url: image_data_url(image.mime_type(), image.data()),
|
||||
},
|
||||
}
|
||||
}));
|
||||
} else if !attachments.is_empty() {
|
||||
text.push_str(&format!(
|
||||
"\n[{} image attachment(s) omitted: model does not support images]",
|
||||
attachments.len()
|
||||
));
|
||||
}
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(text)),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: Some(call_id.clone()),
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
Item::Reasoning { text, .. } => {
|
||||
// Reasoning is treated as assistant text in OpenAI
|
||||
// (OpenAI doesn't have native reasoning support like Claude)
|
||||
if let Some(ref mut existing) = pending_assistant_text {
|
||||
existing.push_str(text);
|
||||
} else {
|
||||
pending_assistant_text = Some(text.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending items
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
Self::flush_pending_tool_result_images(&mut messages, &mut pending_tool_result_images);
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
fn flush_pending_assistant(
|
||||
&self,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
pending_tool_calls: &mut Vec<OpenAIToolCall>,
|
||||
pending_assistant_text: &mut Option<String>,
|
||||
) {
|
||||
if !pending_tool_calls.is_empty() || pending_assistant_text.is_some() {
|
||||
messages.push(OpenAIMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: pending_assistant_text.take().map(OpenAIContent::Text),
|
||||
tool_calls: std::mem::take(pending_tool_calls),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tool(&self, tool: &ToolDefinition) -> OpenAITool {
|
||||
OpenAITool {
|
||||
r#type: "function".to_string(),
|
||||
function: OpenAIToolFunction {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn vision_cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
vision: true,
|
||||
..cap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_simple_request() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new().system("System prompt").user("Hello");
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.model, "gpt-4o");
|
||||
assert_eq!(body.messages.len(), 2);
|
||||
assert_eq!(body.messages[0].role, "system");
|
||||
assert_eq!(body.messages[1].role, "user");
|
||||
|
||||
if let Some(OpenAIContent::Text(text)) = &body.messages[0].content {
|
||||
assert_eq!(text, "System prompt");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_with_tool() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new()
|
||||
.user("Check weather")
|
||||
.tool(ToolDefinition::new("weather").description("Get weather"));
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
assert_eq!(body.tools.len(), 1);
|
||||
assert_eq!(body.tools[0].function.name, "weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_legacy_max_tokens() {
|
||||
let scheme = OpenAIScheme::new().with_legacy_max_tokens(true);
|
||||
let request = Request::new().user("Hello").max_tokens(100);
|
||||
|
||||
let body = scheme.build_request("llama3", &request, &cap());
|
||||
|
||||
assert_eq!(body.max_tokens, Some(100));
|
||||
assert!(body.max_completion_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_modern_max_tokens() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new().user("Hello").max_tokens(100);
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.max_completion_tokens, Some(100));
|
||||
assert!(body.max_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_projected_when_supported() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let mut request = Request::new().user("Hello");
|
||||
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Other(
|
||||
"provider-native".into(),
|
||||
)));
|
||||
let capability = ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::Effort),
|
||||
..cap()
|
||||
};
|
||||
|
||||
let body = scheme.build_request("gpt-5", &request, &capability);
|
||||
|
||||
assert_eq!(body.reasoning_effort.as_deref(), Some("provider-native"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_reasoning_not_projected_to_openai_chat() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let mut request = Request::new().user("Hello");
|
||||
request.config.reasoning = Some(ReasoningControl::BudgetTokens(4096));
|
||||
let capability = ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::Both),
|
||||
..cap()
|
||||
};
|
||||
|
||||
let body = scheme.build_request("gpt-5", &request, &capability);
|
||||
|
||||
assert!(body.reasoning_effort.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_and_result() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new()
|
||||
.user("Check weather")
|
||||
.item(Item::tool_call(
|
||||
"call_123",
|
||||
"get_weather",
|
||||
r#"{"city":"Tokyo"}"#,
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.messages.len(), 3);
|
||||
assert_eq!(body.messages[0].role, "user");
|
||||
assert_eq!(body.messages[1].role, "assistant");
|
||||
assert_eq!(body.messages[1].tool_calls.len(), 1);
|
||||
assert_eq!(body.messages[2].role, "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_tool_results_precede_durable_image_projection() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||
let request = Request::new()
|
||||
.item(Item::tool_call("call_image", "ViewImage", "{}"))
|
||||
.item(Item::tool_call("call_text", "Read", "{}"))
|
||||
.item(Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"Attached image",
|
||||
None,
|
||||
false,
|
||||
vec![crate::tool::Attachment::Image(
|
||||
crate::tool::ImageAttachment::new("image/png", image),
|
||||
)],
|
||||
))
|
||||
.item(Item::tool_result_item(
|
||||
"call_text",
|
||||
"Read text",
|
||||
None,
|
||||
false,
|
||||
));
|
||||
let json = serde_json::to_value(
|
||||
&scheme
|
||||
.build_request("gpt-4o", &request, &vision_cap())
|
||||
.messages,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(json[0]["role"], "assistant");
|
||||
assert_eq!(json[1]["role"], "tool");
|
||||
assert_eq!(json[2]["role"], "tool");
|
||||
assert_eq!(json[3]["role"], "user");
|
||||
assert_eq!(json[3]["content"][0]["type"], "image_url");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_tool_image_is_deterministically_lowered_to_following_user_content() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||
let attachment = crate::tool::Attachment::Image(crate::tool::ImageAttachment::new(
|
||||
"image/png",
|
||||
image.clone(),
|
||||
));
|
||||
let item = Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"Attached image",
|
||||
None,
|
||||
false,
|
||||
vec![attachment],
|
||||
);
|
||||
let persisted = serde_json::to_string(&item).unwrap();
|
||||
assert!(persisted.contains("attachments"));
|
||||
let restored: Item = serde_json::from_str(&persisted).unwrap();
|
||||
|
||||
let request = Request::new()
|
||||
.item(Item::tool_call(
|
||||
"call_image",
|
||||
"ViewImage",
|
||||
r#"{"path":"a.png"}"#,
|
||||
))
|
||||
.item(restored);
|
||||
let body = scheme.build_request("gpt-4o", &request, &vision_cap());
|
||||
let json = serde_json::to_value(&body.messages).unwrap();
|
||||
let rebuilt = serde_json::to_value(
|
||||
&scheme
|
||||
.build_request("gpt-4o", &request, &vision_cap())
|
||||
.messages,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rebuilt, json);
|
||||
|
||||
assert_eq!(json[0]["role"], "assistant");
|
||||
assert_eq!(json[1]["role"], "tool");
|
||||
assert_eq!(json[2]["role"], "user");
|
||||
assert_eq!(json[2]["content"][0]["type"], "image_url");
|
||||
assert!(
|
||||
json[2]["content"][0]["image_url"]["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
|
||||
let mut no_vision = cap();
|
||||
no_vision.vision = false;
|
||||
let disabled =
|
||||
serde_json::to_string(&scheme.build_request("gpt-4o", &request, &no_vision)).unwrap();
|
||||
assert!(!disabled.contains("data:image"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! `impl Scheme for OpenAIScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ConfigWarning,
|
||||
event::Event,
|
||||
scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
};
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
impl Scheme for OpenAIScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.openai.com"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/v1/chat/completions".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&req).expect("OpenAIRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
// `data: [DONE]` は終端マーカー
|
||||
if data.trim() == "[DONE]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// OpenAI Chat Completions API は top_k を受け付けない
|
||||
if config.top_k.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported("top_k", "OpenAI Chat"));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! OpenAI Responses scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有の能力テーブルは高レベルの client 構築層の責務。
|
||||
//! ここでは wire の保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
//! OpenAI Responses API スキーマ (`/v1/responses`)
|
||||
//!
|
||||
//! Chat Completions とは別物の item-based wire format。reasoning item と
|
||||
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
||||
//! 流れる。
|
||||
//!
|
||||
//! - リクエスト JSON 生成: `request`
|
||||
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: `events`
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::OpenAIResponsesState;
|
||||
|
||||
/// OpenAI Responses scheme 本体。
|
||||
///
|
||||
/// `store` / `include_encrypted_content` / `send_max_output_tokens` /
|
||||
/// `send_sampling_params` は scheme 固定の wire 設定で、デフォルトは
|
||||
/// 公式 OpenAI Responses API 向け (stateless + ZDR + `max_output_tokens`
|
||||
/// / `temperature` / `top_p` 送出可)。受理パラメータが subset の
|
||||
/// 互換 backend では client 構築層で `send_max_output_tokens=false` /
|
||||
/// `send_sampling_params=false` に上書きする。`ModelCapability` には
|
||||
/// 入れない(モデル能力ではなく wire policy)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAIResponsesScheme {
|
||||
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
|
||||
pub store: bool,
|
||||
/// `include: ["reasoning.encrypted_content"]` を付けるか。
|
||||
/// `store=false` で reasoning を使うなら必須。
|
||||
pub include_encrypted_content: bool,
|
||||
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
|
||||
/// 受理するが、互換 backend によっては `Unsupported parameter` で
|
||||
/// 400 を返すため、その経路では `false` にする。
|
||||
pub send_max_output_tokens: bool,
|
||||
/// `temperature` / `top_p` を body に載せるか。公式 OpenAI Responses API
|
||||
/// は受理するが、互換 backend によっては `Unsupported parameter` で
|
||||
/// 400 を返すため、その経路では `false` にする。
|
||||
pub send_sampling_params: bool,
|
||||
}
|
||||
|
||||
impl Default for OpenAIResponsesScheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
store: false,
|
||||
include_encrypted_content: true,
|
||||
send_max_output_tokens: true,
|
||||
send_sampling_params: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
|
||||
/// `send_max_output_tokens=true`, `send_sampling_params=true`)。
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `store` を上書き。
|
||||
pub fn with_store(mut self, store: bool) -> Self {
|
||||
self.store = store;
|
||||
self
|
||||
}
|
||||
|
||||
/// `include: ["reasoning.encrypted_content"]` の有無を上書き。
|
||||
pub fn with_include_encrypted_content(mut self, include: bool) -> Self {
|
||||
self.include_encrypted_content = include;
|
||||
self
|
||||
}
|
||||
|
||||
/// `max_output_tokens` を body に載せるかを上書き。
|
||||
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
|
||||
self.send_max_output_tokens = send;
|
||||
self
|
||||
}
|
||||
|
||||
/// `temperature` / `top_p` を body に載せるかを上書き。
|
||||
pub fn with_send_sampling_params(mut self, send: bool) -> Self {
|
||||
self.send_sampling_params = send;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
//! OpenAI Responses API リクエスト body 生成
|
||||
//!
|
||||
//! Chat Completions の `messages` と違い、Responses は `input[]` の
|
||||
//! item 配列で reasoning / function_call / function_call_output が
|
||||
//! first-class。`Item` を素に近い形で `input[]` に投影できる。
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
|
||||
},
|
||||
tool::Attachment,
|
||||
};
|
||||
|
||||
use super::OpenAIResponsesScheme;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum FunctionCallOutputBody {
|
||||
Text(String),
|
||||
ContentItems(Vec<FunctionCallOutputContentItem>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum FunctionCallOutputContentItem {
|
||||
InputText { text: String },
|
||||
InputImage { image_url: String },
|
||||
}
|
||||
|
||||
/// `/v1/responses` のリクエスト body。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponsesRequest {
|
||||
pub model: String,
|
||||
/// システムプロンプト相当。`input[]` とは別フィールド。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
pub input: Vec<InputItem>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<ResponseTool>,
|
||||
/// 常時 `"auto"` を送る。scheme 固定値。
|
||||
pub tool_choice: &'static str,
|
||||
/// 常時 `true` を送る。scheme 固定値。
|
||||
pub parallel_tool_calls: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<ReasoningConfig>,
|
||||
/// ZDR / stateless 運用では `false`。
|
||||
pub store: bool,
|
||||
/// 常時 `true`。
|
||||
pub stream: bool,
|
||||
/// `["reasoning.encrypted_content"]` 等。
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub include: Vec<&'static str>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
|
||||
/// 400 で弾く。scheme の `send_max_output_tokens` が `false` のときは
|
||||
/// `None` のまま送る (skip_serializing_if で除外)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
|
||||
/// `temperature` / `top_p` を 400 で弾く。scheme の
|
||||
/// `send_sampling_params` が `false` のときは `None` のまま送る。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// 会話単位の安定キー。明示キーを必要とする backend では、
|
||||
/// 呼び出し側が安定した conversation identifier を渡す。
|
||||
/// `Request::cache_key` が `None` のときはキー自体を送らない。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
/// reasoning 制御。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ReasoningConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
|
||||
pub summary: &'static str,
|
||||
}
|
||||
|
||||
/// `input[]` の 1 要素。
|
||||
///
|
||||
/// Responses API の item 型を素に近い形で投影する。未対応 type は
|
||||
/// 無視(reasoning 送信時に `content: []` の場合は `None` として弾く)。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum InputItem {
|
||||
/// 会話メッセージ。user / assistant / developer のいずれか。
|
||||
/// `Role::System` items は `developer` として投影する。OpenAI
|
||||
/// Responses 互換 backend の一部は `role: "system"` を拒否するため、
|
||||
/// system 相当の挿入には `role: "developer"` を使う。
|
||||
Message {
|
||||
role: &'static str,
|
||||
content: Vec<InputContent>,
|
||||
},
|
||||
/// 過去の function tool 呼び出し(assistant 側)。
|
||||
FunctionCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
/// JSON 文字列(object でなくても正規化済み)。
|
||||
arguments: String,
|
||||
},
|
||||
/// function tool の結果(user 側)。
|
||||
FunctionCallOutput {
|
||||
call_id: String,
|
||||
output: FunctionCallOutputBody,
|
||||
},
|
||||
/// reasoning item。`encrypted_content` があれば必ず添える。
|
||||
Reasoning {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
/// Responses API は reasoning item に `summary` フィールドを必須で
|
||||
/// 要求する(中身が空でも `[]` として送る必要がある)。GPT-5 など
|
||||
/// summary を返さないモデル + reasoning effort 指定なしのターンでは
|
||||
/// summary text が一切付かないので、ここを skip すると 400
|
||||
/// "Missing required parameter: 'input[N].summary'" で弾かれる。
|
||||
summary: Vec<ReasoningSummaryPart>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
content: Vec<ReasoningContentPart>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
encrypted_content: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// メッセージ content_part。role で input/output を使い分ける。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum InputContent {
|
||||
/// user / developer 側のテキスト
|
||||
InputText { text: String },
|
||||
/// user 側の画像
|
||||
/// assistant 側のテキスト
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ReasoningSummaryPart {
|
||||
SummaryText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ReasoningContentPart {
|
||||
ReasoningText { text: String },
|
||||
}
|
||||
|
||||
/// Responses 用 tool 定義。Chat と違い function キーでネストせず
|
||||
/// トップレベルに `name` / `parameters` が載る。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponseTool {
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: &'static str,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// OpenAI Responses API は `type:"object"` のパラメータスキーマに
|
||||
/// `properties` が存在することを要求する。schemars は引数なし struct
|
||||
/// から `properties` を含まない最小スキーマを出すので、serialize
|
||||
/// 時に空オブジェクトを補う。
|
||||
#[serde(serialize_with = "serialize_parameters")]
|
||||
pub parameters: Value,
|
||||
/// Structured output モード制御。デフォルト false。
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
fn serialize_parameters<S: Serializer>(value: &Value, s: S) -> Result<S::Ok, S::Error> {
|
||||
if let Some(obj) = value.as_object()
|
||||
&& obj.get("type").and_then(Value::as_str) == Some("object")
|
||||
&& !obj.contains_key("properties")
|
||||
{
|
||||
let mut patched = obj.clone();
|
||||
patched.insert("properties".to_string(), Value::Object(Default::default()));
|
||||
return Value::Object(patched).serialize(s);
|
||||
}
|
||||
value.serialize(s)
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// `Request` から wire 形式の body を組み立てる。
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> ResponsesRequest {
|
||||
let input = convert_items_to_input(&request.items, capability.vision);
|
||||
let tools = request.tools.iter().map(convert_tool).collect();
|
||||
|
||||
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
|
||||
// request 側で effort が指定されているときだけ reasoning を付ける。
|
||||
let supports_effort = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
|
||||
);
|
||||
let reasoning = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_effort)
|
||||
.map(|effort| ReasoningConfig {
|
||||
effort: match effort {
|
||||
ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
|
||||
ReasoningControl::BudgetTokens(_) => None,
|
||||
},
|
||||
summary: "auto",
|
||||
})
|
||||
.filter(|reasoning| reasoning.effort.is_some());
|
||||
|
||||
let include: Vec<&'static str> = if self.include_encrypted_content {
|
||||
vec!["reasoning.encrypted_content"]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
ResponsesRequest {
|
||||
model: model.to_string(),
|
||||
instructions: request.system_prompt.clone(),
|
||||
input,
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
reasoning,
|
||||
store: self.store,
|
||||
stream: true,
|
||||
include,
|
||||
max_output_tokens: if self.send_max_output_tokens {
|
||||
request.config.max_tokens
|
||||
} else {
|
||||
None
|
||||
},
|
||||
temperature: if self.send_sampling_params {
|
||||
request.config.temperature
|
||||
} else {
|
||||
None
|
||||
},
|
||||
top_p: if self.send_sampling_params {
|
||||
request.config.top_p
|
||||
} else {
|
||||
None
|
||||
},
|
||||
prompt_cache_key: request.cache_key.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Item` 列を `input[]` に変換する。
|
||||
fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputItem> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
|
||||
match role {
|
||||
Role::User => ("user", |t| InputContent::InputText { text: t }),
|
||||
Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }),
|
||||
Role::System => ("developer", |t| InputContent::InputText { text: t }),
|
||||
};
|
||||
let parts: Vec<InputContent> = content
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
ContentPart::Text { text } => text_variant(text.clone()),
|
||||
ContentPart::Refusal { refusal } => text_variant(refusal.clone()),
|
||||
})
|
||||
.collect();
|
||||
out.push(InputItem::Message {
|
||||
role: role_str,
|
||||
content: parts,
|
||||
});
|
||||
}
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
// 非 object / 旧形式の "null" を "{}" に正規化。
|
||||
let normalized = parse_tool_arguments(arguments).to_string();
|
||||
out.push(InputItem::FunctionCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: normalized,
|
||||
});
|
||||
}
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
..
|
||||
} => {
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
let output = if attachments.is_empty() {
|
||||
FunctionCallOutputBody::Text(text)
|
||||
} else if supports_images {
|
||||
let mut parts = vec![FunctionCallOutputContentItem::InputText { text }];
|
||||
parts.extend(attachments.iter().map(|attachment| {
|
||||
let Attachment::Image(image) = attachment;
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: image_data_url(image.mime_type(), image.data()),
|
||||
}
|
||||
}));
|
||||
FunctionCallOutputBody::ContentItems(parts)
|
||||
} else {
|
||||
FunctionCallOutputBody::Text(format!(
|
||||
"{text}\n[{} image attachment(s) omitted: model does not support images]",
|
||||
attachments.len()
|
||||
))
|
||||
};
|
||||
out.push(InputItem::FunctionCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output,
|
||||
});
|
||||
}
|
||||
Item::Reasoning {
|
||||
id,
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
..
|
||||
} => {
|
||||
let summary_parts = summary
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| ReasoningSummaryPart::SummaryText { text: s.clone() })
|
||||
.collect();
|
||||
let content_parts = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![ReasoningContentPart::ReasoningText { text: text.clone() }]
|
||||
};
|
||||
out.push(InputItem::Reasoning {
|
||||
id: id.clone(),
|
||||
summary: summary_parts,
|
||||
content: content_parts,
|
||||
encrypted_content: encrypted_content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn convert_tool(tool: &ToolDefinition) -> ResponseTool {
|
||||
ResponseTool {
|
||||
r#type: "function",
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
strict: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, ReasoningControl, ReasoningEffort, ReasoningSupport,
|
||||
StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap_with_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: Some(ReasoningSupport::Effort),
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_no_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
reasoning: None,
|
||||
..cap_with_reasoning()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_defaults_to_stateless_zdr() {
|
||||
let s = OpenAIResponsesScheme::new();
|
||||
assert!(!s.store);
|
||||
assert!(s.include_encrypted_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_encrypted_content_when_enabled() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.include, vec!["reasoning.encrypted_content"]);
|
||||
assert!(!body.store);
|
||||
assert!(body.stream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_from_system_prompt() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().system("be terse").user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.instructions.as_deref(), Some("be terse"));
|
||||
assert_eq!(body.input.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_choice_and_parallel_are_fixed() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.tool_choice, "auto");
|
||||
assert!(body.parallel_tool_calls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_uses_input_text() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[0] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "user");
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], InputContent::InputText { text } if text == "hi"));
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_role_item_is_projected_as_developer() {
|
||||
// Some compatible backends reject `role: "system"` in input[].
|
||||
// Project in-conversation system notes as `role: "developer"` so
|
||||
// both official and compatible backends can accept them.
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("hi")
|
||||
.item(Item::system_message("[notify] hello"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "developer");
|
||||
assert!(
|
||||
matches!(&content[0], InputContent::InputText { text } if text == "[notify] hello"),
|
||||
);
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_message_uses_output_text() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").assistant("hello");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "assistant");
|
||||
assert!(
|
||||
matches!(&content[0], InputContent::OutputText { text } if text == "hello")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_and_result_become_function_items() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("run")
|
||||
.item(Item::tool_call("c1", "t", r#"{"a":1}"#))
|
||||
.item(Item::tool_result("c1", "ok"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(matches!(body.input[1], InputItem::FunctionCall { .. }));
|
||||
assert!(matches!(
|
||||
body.input[2],
|
||||
InputItem::FunctionCallOutput { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_item_round_trips_encrypted_content() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let item = Item::reasoning("inner")
|
||||
.with_reasoning_summary(vec!["s1".into()])
|
||||
.with_encrypted_content("ENC");
|
||||
let req = Request::new().user("hi").item(item);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Reasoning {
|
||||
summary,
|
||||
content,
|
||||
encrypted_content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(summary.len(), 1);
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(encrypted_content.as_deref(), Some("ENC"));
|
||||
}
|
||||
_ => panic!("expected reasoning"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_reasoning_items_are_preserved_across_user_turns() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let old_reasoning = Item::reasoning("old").with_encrypted_content("OLD_ENC");
|
||||
let current_reasoning = Item::reasoning("current").with_encrypted_content("CURRENT_ENC");
|
||||
let req = Request::new()
|
||||
.user("old prompt")
|
||||
.item(old_reasoning)
|
||||
.assistant("old answer")
|
||||
.user("new prompt")
|
||||
.item(current_reasoning);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let encrypted: Vec<_> = body
|
||||
.input
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
InputItem::Reasoning {
|
||||
encrypted_content, ..
|
||||
} => encrypted_content.as_deref(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(encrypted, vec!["OLD_ENC", "CURRENT_ENC"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_is_kept_across_function_call_loop() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("run tool")
|
||||
.item(Item::reasoning("plan").with_encrypted_content("ENC"))
|
||||
.item(Item::tool_call("c1", "tool", "{}"))
|
||||
.item(Item::tool_result("c1", "ok"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(matches!(body.input[1], InputItem::Reasoning { .. }));
|
||||
assert!(matches!(body.input[2], InputItem::FunctionCall { .. }));
|
||||
assert!(matches!(
|
||||
body.input[3],
|
||||
InputItem::FunctionCallOutput { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_field_is_always_serialized() {
|
||||
// Responses API は reasoning item に `summary` を必須で要求する。
|
||||
// summary が空でも wire 上に `summary: []` として残らないと、
|
||||
// backend によっては missing required parameter として拒否される。
|
||||
// reasoning effort 未指定のターンでは summary text が付かないことが
|
||||
// あるため、空のままでも skip しないこと。
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let item = Item::reasoning("").with_encrypted_content("ENC");
|
||||
let req = Request::new().user("hi").item(item);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
let reasoning_item = &json["input"][1];
|
||||
assert_eq!(reasoning_item["type"], "reasoning");
|
||||
assert!(
|
||||
reasoning_item.get("summary").is_some(),
|
||||
"summary key must be present even when empty, got: {reasoning_item}"
|
||||
);
|
||||
assert_eq!(reasoning_item["summary"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_projected_when_supported() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut req = Request::new().user("hi");
|
||||
req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let reasoning = body.reasoning.expect("reasoning should be set");
|
||||
assert_eq!(reasoning.effort.as_deref(), Some("high"));
|
||||
assert_eq!(reasoning.summary, "auto");
|
||||
let json = serde_json::to_value(reasoning).unwrap();
|
||||
assert!(
|
||||
json.get("context").is_none(),
|
||||
"reasoning.context must not be serialized, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_omitted_when_unsupported() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut req = Request::new().user("hi");
|
||||
req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
|
||||
let body = scheme.build_request("gpt-4o", &req, &cap_no_reasoning());
|
||||
assert!(body.reasoning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_passed_through_by_default() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").max_tokens(100);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.max_output_tokens, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_dropped_when_send_disabled() {
|
||||
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(false);
|
||||
let req = Request::new().user("hi").max_tokens(100);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.max_output_tokens, None);
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("max_output_tokens").is_none(),
|
||||
"max_output_tokens key must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_params_passed_through_by_default() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").temperature(0.4).top_p(0.9);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.temperature, Some(0.4));
|
||||
assert_eq!(body.top_p, Some(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_params_dropped_when_send_disabled() {
|
||||
let scheme = OpenAIResponsesScheme::new().with_send_sampling_params(false);
|
||||
let req = Request::new().user("hi").temperature(0.4).top_p(0.9);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.temperature, None);
|
||||
assert_eq!(body.top_p, None);
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("temperature").is_none() && json.get("top_p").is_none(),
|
||||
"temperature/top_p keys must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_passed_through_when_set() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").cache_key("session-abc");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.prompt_cache_key.as_deref(), Some("session-abc"));
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["prompt_cache_key"], "session-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_omitted_when_none() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(body.prompt_cache_key.is_none());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("prompt_cache_key").is_none(),
|
||||
"prompt_cache_key key must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_without_properties_is_normalized() {
|
||||
// schemars は引数なし struct から `type:"object"` だけのスキーマを
|
||||
// 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので
|
||||
// 送る直前に空オブジェクトを補うのを確認。
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let raw_schema = serde_json::json!({ "type": "object" });
|
||||
let req = Request::new().tool(
|
||||
ToolDefinition::new("empty")
|
||||
.description("no args")
|
||||
.input_schema(raw_schema),
|
||||
);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["tools"][0]["parameters"]["type"], "object");
|
||||
assert!(
|
||||
json["tools"][0]["parameters"]["properties"].is_object(),
|
||||
"properties must be present as an object, got: {}",
|
||||
json["tools"][0]["parameters"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_with_properties_is_untouched() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let raw_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": { "path": { "type": "string" } },
|
||||
"required": ["path"]
|
||||
});
|
||||
let req = Request::new().tool(
|
||||
ToolDefinition::new("t")
|
||||
.description("d")
|
||||
.input_schema(raw_schema.clone()),
|
||||
);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["tools"][0]["parameters"], raw_schema);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_body_has_expected_shape() {
|
||||
// wire 形式が崩れていないかのスモークテスト
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.system("sys")
|
||||
.user("hi")
|
||||
.tool(ToolDefinition::new("t").description("d"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["model"], "gpt-5");
|
||||
assert_eq!(json["instructions"], "sys");
|
||||
assert_eq!(json["tool_choice"], "auto");
|
||||
assert_eq!(json["parallel_tool_calls"], true);
|
||||
assert_eq!(json["store"], false);
|
||||
assert_eq!(json["stream"], true);
|
||||
assert_eq!(json["include"][0], "reasoning.encrypted_content");
|
||||
assert_eq!(json["tools"][0]["type"], "function");
|
||||
assert_eq!(json["tools"][0]["name"], "t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_tool_image_uses_function_call_output_content_items() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||
let item = Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"Attached image",
|
||||
None,
|
||||
false,
|
||||
vec![crate::tool::Attachment::Image(
|
||||
crate::tool::ImageAttachment::new("image/png", image),
|
||||
)],
|
||||
);
|
||||
let persisted = serde_json::to_string(&item).unwrap();
|
||||
let restored: Item = serde_json::from_str(&persisted).unwrap();
|
||||
let req = Request::new()
|
||||
.item(Item::tool_call(
|
||||
"call_image",
|
||||
"ViewImage",
|
||||
r#"{"path":"a.png"}"#,
|
||||
))
|
||||
.item(restored);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
|
||||
assert_eq!(json["input"][1]["type"], "function_call_output");
|
||||
assert_eq!(json["input"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(json["input"][1]["output"][0]["type"], "input_text");
|
||||
assert_eq!(json["input"][1]["output"][1]["type"], "input_image");
|
||||
assert!(
|
||||
json["input"][1]["output"][1]["image_url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
let rebuilt =
|
||||
serde_json::to_value(scheme.build_request("gpt-5", &req, &cap_with_reasoning()))
|
||||
.unwrap();
|
||||
assert_eq!(rebuilt["input"], json["input"]);
|
||||
|
||||
let mut no_vision = cap_with_reasoning();
|
||||
no_vision.vision = false;
|
||||
let disabled =
|
||||
serde_json::to_string(&scheme.build_request("gpt-5", &req, &no_vision)).unwrap();
|
||||
assert!(!disabled.contains("data:image"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! `impl Scheme for OpenAIResponsesScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ConfigWarning,
|
||||
event::Event,
|
||||
scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
};
|
||||
|
||||
use super::OpenAIResponsesScheme;
|
||||
|
||||
pub use super::events::OpenAIResponsesState;
|
||||
|
||||
impl Scheme for OpenAIResponsesScheme {
|
||||
type State = OpenAIResponsesState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
// `/v1` は base_url 側に寄せる。互換 backend を使う場合も、
|
||||
// base URL を差し替えるだけで同じ `/responses` path を使える。
|
||||
"https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/responses".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let body = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&body).expect("ResponsesRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
super::events::parse_sse(event_type, data, state)
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// Some compatible backends reject `max_output_tokens` with HTTP 400.
|
||||
// If the scheme was built with `send_max_output_tokens=false`, body
|
||||
// projection is already disabled; only notify that the user's intent
|
||||
// was dropped.
|
||||
if !self.send_max_output_tokens && config.max_tokens.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"max_tokens",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
// Same for `temperature` / `top_p` on compatible backends that
|
||||
// reject unsupported sampling parameters.
|
||||
if !self.send_sampling_params {
|
||||
if config.temperature.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"temperature",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
if config.top_p.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"top_p",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
|
||||
//! HTTP クライアントで扱う。
|
||||
//!
|
||||
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲し、backend 固有の
|
||||
//! HTTP policy は [`TransportPolicy`] で明示的に差し込む。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use reqwest::header::{
|
||||
ACCEPT, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue,
|
||||
RETRY_AFTER, TRANSFER_ENCODING,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::auth::{AuthProvider, AuthRequirement};
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::{ConfigWarning, LlmClient, ResponseStream};
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::scheme::Scheme;
|
||||
use super::types::{Request, RequestConfig};
|
||||
|
||||
pub const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
pub const DEFAULT_FIRST_STREAM_EVENT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 認証設定をリクエスト時に使える形へ解決したランタイム表現。
|
||||
///
|
||||
/// - `None`: 認証ヘッダを送らない(Ollama 等の opt-out)
|
||||
/// - `ApiKey`: 静的な API key 文字列
|
||||
/// - `Custom`: リクエスト毎に動的にヘッダを組み立てる
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResolvedAuth {
|
||||
None,
|
||||
ApiKey(String),
|
||||
Custom(Arc<dyn AuthProvider>),
|
||||
}
|
||||
|
||||
impl ResolvedAuth {
|
||||
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
|
||||
///
|
||||
/// - `ResolvedAuth::None` は認証を付けない宣言なので、どの
|
||||
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
|
||||
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
|
||||
/// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」
|
||||
/// 宣言なので、scheme が要求する形式によらず受け入れる
|
||||
pub fn matches(&self, req: AuthRequirement) -> bool {
|
||||
match (self, req) {
|
||||
(Self::None, _) => true,
|
||||
(Self::Custom(_), _) => true,
|
||||
(
|
||||
Self::ApiKey(_),
|
||||
AuthRequirement::Bearer
|
||||
| AuthRequirement::XApiKey
|
||||
| AuthRequirement::QueryParam { .. },
|
||||
) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body encoding policy used by [`HttpTransport`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequestBodyEncoding {
|
||||
/// Send the request body as plain JSON.
|
||||
Json,
|
||||
/// Send the request body as zstd-compressed JSON with `Content-Encoding: zstd`.
|
||||
ZstdJson,
|
||||
}
|
||||
|
||||
impl Default for RequestBodyEncoding {
|
||||
fn default() -> Self {
|
||||
Self::Json
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversation header policy used by [`HttpTransport`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConversationHeaderPolicy {
|
||||
/// Do not derive any transport headers from [`Request::cache_key`].
|
||||
None,
|
||||
/// Send OpenAI-compatible conversation headers from [`Request::cache_key`].
|
||||
OpenAiCompatible {
|
||||
/// Send the legacy `session_id` header in addition to `session-id`.
|
||||
include_legacy_session_id: bool,
|
||||
/// Send `thread-id` with the same value as `session-id`.
|
||||
include_thread_id: bool,
|
||||
/// Send `x-client-request-id` with the same value as `session-id`.
|
||||
include_client_request_id: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ConversationHeaderPolicy {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-specific HTTP transport policy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TransportPolicy {
|
||||
/// How to serialize and encode the HTTP request body.
|
||||
pub request_body_encoding: RequestBodyEncoding,
|
||||
/// Optional backend-specific conversation headers derived from request metadata.
|
||||
pub conversation_headers: ConversationHeaderPolicy,
|
||||
}
|
||||
|
||||
impl TransportPolicy {
|
||||
/// Plain JSON requests with no derived conversation headers.
|
||||
pub fn standard() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// OpenAI-compatible backend profile that uses zstd JSON bodies and
|
||||
/// conversation headers derived from [`Request::cache_key`].
|
||||
pub fn openai_compatible_zstd() -> Self {
|
||||
Self {
|
||||
request_body_encoding: RequestBodyEncoding::ZstdJson,
|
||||
conversation_headers: ConversationHeaderPolicy::OpenAiCompatible {
|
||||
include_legacy_session_id: true,
|
||||
include_thread_id: true,
|
||||
include_client_request_id: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
fn header_value_for_diagnostics(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn response_header_diagnostics(headers: &HeaderMap) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"content_type": header_value_for_diagnostics(headers, CONTENT_TYPE.as_str()),
|
||||
"content_encoding": header_value_for_diagnostics(headers, CONTENT_ENCODING.as_str()),
|
||||
"transfer_encoding": header_value_for_diagnostics(headers, TRANSFER_ENCODING.as_str()),
|
||||
"content_length": header_value_for_diagnostics(headers, CONTENT_LENGTH.as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
fn request_header_diagnostics(headers: &HeaderMap) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"content_type": header_value_for_diagnostics(headers, CONTENT_TYPE.as_str()),
|
||||
"content_encoding": header_value_for_diagnostics(headers, CONTENT_ENCODING.as_str()),
|
||||
"accept": header_value_for_diagnostics(headers, ACCEPT.as_str()),
|
||||
"openai_beta": header_value_for_diagnostics(headers, "openai-beta"),
|
||||
"session_id_present": headers.contains_key("session-id"),
|
||||
"thread_id_present": headers.contains_key("thread-id"),
|
||||
"legacy_session_id_present": headers.contains_key("session_id"),
|
||||
"legacy_thread_id_present": headers.contains_key("thread_id"),
|
||||
"x_client_request_id_present": headers.contains_key("x-client-request-id"),
|
||||
"chatgpt_account_id_present": headers.contains_key("chatgpt-account-id"),
|
||||
})
|
||||
}
|
||||
|
||||
fn sse_error_context(status: u16, headers: &serde_json::Value, source: &str) -> String {
|
||||
let field = |name: &str| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("<none>")
|
||||
};
|
||||
format!(
|
||||
"SSE stream parse failed after HTTP {status}: {source}; content-type={}, content-encoding={}, transfer-encoding={}, content-length={}",
|
||||
field("content_type"),
|
||||
field("content_encoding"),
|
||||
field("transfer_encoding"),
|
||||
field("content_length")
|
||||
)
|
||||
}
|
||||
|
||||
/// scheme 共通の HTTP 通信層。
|
||||
pub struct HttpTransport<S: Scheme> {
|
||||
http_client: reqwest::Client,
|
||||
scheme: S,
|
||||
model_id: String,
|
||||
base_url: String,
|
||||
auth: ResolvedAuth,
|
||||
capability: ModelCapability,
|
||||
policy: TransportPolicy,
|
||||
}
|
||||
|
||||
impl<S: Scheme> HttpTransport<S> {
|
||||
/// 新しい transport を作る。`base_url` は末尾スラッシュの有無を
|
||||
/// どちらでも受け付ける(内部で正規化)。
|
||||
pub fn new(
|
||||
scheme: S,
|
||||
model_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
auth: ResolvedAuth,
|
||||
capability: ModelCapability,
|
||||
) -> Self {
|
||||
let base_url = base_url.into();
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
Self {
|
||||
http_client: reqwest::Client::new(),
|
||||
scheme,
|
||||
model_id: model_id.into(),
|
||||
base_url,
|
||||
auth,
|
||||
capability,
|
||||
policy: TransportPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a backend-specific HTTP transport policy.
|
||||
pub fn with_transport_policy(mut self, policy: TransportPolicy) -> Self {
|
||||
self.policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// カスタム HTTP クライアントを差し込む(テスト等)。
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
}
|
||||
|
||||
fn build_url(&self) -> String {
|
||||
let path = self.scheme.path(&self.model_id);
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
// Gemini のようにクエリパラメータで認証する場合は URL にキーを追記する
|
||||
if let (AuthRequirement::QueryParam { name }, ResolvedAuth::ApiKey(key)) =
|
||||
(self.scheme.required_auth(), &self.auth)
|
||||
{
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
format!("{url}{sep}{name}={key}")
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_headers(&self) -> Result<HeaderMap, ClientError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
match (&self.auth, self.scheme.required_auth()) {
|
||||
(ResolvedAuth::None, _) | (_, AuthRequirement::None) => {}
|
||||
(ResolvedAuth::Custom(provider), _) => {
|
||||
for (name, mut value) in provider.headers().await? {
|
||||
value.set_sensitive(true);
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
(ResolvedAuth::ApiKey(key), AuthRequirement::Bearer) => {
|
||||
let mut val = HeaderValue::from_str(&format!("Bearer {key}"))
|
||||
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
|
||||
val.set_sensitive(true);
|
||||
headers.insert("Authorization", val);
|
||||
}
|
||||
(ResolvedAuth::ApiKey(key), AuthRequirement::XApiKey) => {
|
||||
let mut val = HeaderValue::from_str(key.as_str())
|
||||
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
|
||||
val.set_sensitive(true);
|
||||
headers.insert("x-api-key", val);
|
||||
}
|
||||
(_, AuthRequirement::QueryParam { .. }) => {
|
||||
// クエリパラメータは `build_url` で付与済み
|
||||
}
|
||||
(ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => {
|
||||
// scheme が Custom を要求する組合せに ApiKey は流れてこない想定
|
||||
// (`matches()` で弾かれる)。安全側で何もしない
|
||||
}
|
||||
}
|
||||
|
||||
for (name, value) in self.scheme.additional_headers() {
|
||||
let hv = HeaderValue::from_str(&value)
|
||||
.map_err(|e| ClientError::Config(format!("invalid header {name}: {e}")))?;
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn apply_stream_headers(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
request: &Request,
|
||||
) -> Result<(), ClientError> {
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
|
||||
|
||||
if let ConversationHeaderPolicy::OpenAiCompatible {
|
||||
include_legacy_session_id,
|
||||
include_thread_id,
|
||||
include_client_request_id,
|
||||
} = self.policy.conversation_headers
|
||||
&& let Some(cache_key) = request.cache_key.as_deref()
|
||||
{
|
||||
let value = HeaderValue::from_str(cache_key).map_err(|e| {
|
||||
ClientError::Config(format!("invalid conversation header value: {e}"))
|
||||
})?;
|
||||
headers.insert(HeaderName::from_static("session-id"), value.clone());
|
||||
if include_thread_id {
|
||||
headers.insert(HeaderName::from_static("thread-id"), value.clone());
|
||||
}
|
||||
if include_legacy_session_id {
|
||||
headers.insert(HeaderName::from_static("session_id"), value.clone());
|
||||
}
|
||||
if include_client_request_id {
|
||||
headers.insert(HeaderName::from_static("x-client-request-id"), value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_request_body(
|
||||
&self,
|
||||
body: &serde_json::Value,
|
||||
headers: &mut HeaderMap,
|
||||
) -> Result<RequestBody, ClientError> {
|
||||
match self.policy.request_body_encoding {
|
||||
RequestBodyEncoding::Json => Ok(RequestBody::Json(body.clone())),
|
||||
RequestBodyEncoding::ZstdJson => {
|
||||
let raw = serde_json::to_vec(body)?;
|
||||
let raw_json_bytes = raw.len();
|
||||
let compressed =
|
||||
zstd::stream::encode_all(std::io::Cursor::new(raw), 3).map_err(|e| {
|
||||
ClientError::Config(format!("failed to zstd-compress request: {e}"))
|
||||
})?;
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
|
||||
Ok(RequestBody::CompressedJson {
|
||||
bytes: compressed,
|
||||
raw_json_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RequestBody {
|
||||
Json(serde_json::Value),
|
||||
CompressedJson {
|
||||
bytes: Vec<u8>,
|
||||
raw_json_bytes: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl RequestBody {
|
||||
fn encoding(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Json(_) => "json",
|
||||
Self::CompressedJson { .. } => "zstd",
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_json_bytes(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Json(body) => serde_json::to_vec(body).ok().map(|bytes| bytes.len()),
|
||||
Self::CompressedJson { raw_json_bytes, .. } => Some(*raw_json_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_bytes(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Json(body) => serde_json::to_vec(body).ok().map(|bytes| bytes.len()),
|
||||
Self::CompressedJson { bytes, .. } => Some(bytes.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_kind(auth: &ResolvedAuth) -> &'static str {
|
||||
match auth {
|
||||
ResolvedAuth::None => "none",
|
||||
ResolvedAuth::ApiKey(_) => "api_key",
|
||||
ResolvedAuth::Custom(_) => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_transport_trace(request: &Request, label: &str, data: Value) {
|
||||
if let Some(trace) = &request.transport_trace {
|
||||
trace.emit(label, data);
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_kind(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn request_body_shape_payload(body: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if let Some(input) = body.get("input").and_then(Value::as_array) {
|
||||
let items_json_bytes = serde_json::to_vec(input).map(|bytes| bytes.len()).ok();
|
||||
let mut reasoning_items = 0usize;
|
||||
let mut reasoning_encrypted_content_count = 0usize;
|
||||
let mut reasoning_encrypted_content_bytes = 0usize;
|
||||
for item in input {
|
||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
continue;
|
||||
}
|
||||
reasoning_items += 1;
|
||||
if let Some(encrypted) = item.get("encrypted_content").and_then(Value::as_str) {
|
||||
reasoning_encrypted_content_count += 1;
|
||||
reasoning_encrypted_content_bytes += encrypted.len();
|
||||
}
|
||||
}
|
||||
map.insert("items_len".to_string(), json!(input.len()));
|
||||
map.insert("items_json_bytes".to_string(), json!(items_json_bytes));
|
||||
map.insert("reasoning_items".to_string(), json!(reasoning_items));
|
||||
map.insert(
|
||||
"reasoning_encrypted_content_count".to_string(),
|
||||
json!(reasoning_encrypted_content_count),
|
||||
);
|
||||
map.insert(
|
||||
"reasoning_encrypted_content_bytes".to_string(),
|
||||
json!(reasoning_encrypted_content_bytes),
|
||||
);
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn api_error_code(error: &ClientError) -> Option<&str> {
|
||||
match error {
|
||||
ClientError::Api { code, .. } => code.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api { code, message, .. } => {
|
||||
code.as_deref() == Some("context_length_exceeded")
|
||||
|| message.contains("context_length_exceeded")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
phase: &'static str,
|
||||
) -> Result<reqwest::Response, ClientError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout { phase, timeout })?
|
||||
.map_err(ClientError::Http)
|
||||
}
|
||||
|
||||
impl<S: Scheme + Clone> Clone for HttpTransport<S> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
http_client: self.http_client.clone(),
|
||||
scheme: self.scheme.clone(),
|
||||
model_id: self.model_id.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
auth: self.auth.clone(),
|
||||
capability: self.capability.clone(),
|
||||
policy: self.policy.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// エラーレスポンスを `ClientError::Api` に変換する。
|
||||
async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||
let status = resp.status().as_u16();
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.map(Duration::from_secs);
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let error = json.get("error").unwrap_or(&json);
|
||||
let code = error
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| error.get("type").and_then(|v| v.as_str()))
|
||||
.map(String::from);
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&text)
|
||||
.to_string();
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
message,
|
||||
retry_after,
|
||||
}
|
||||
} else {
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code: None,
|
||||
message: text,
|
||||
retry_after,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
self.scheme.validate_config(config)
|
||||
}
|
||||
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let total_started = Instant::now();
|
||||
let path = self.scheme.path(&self.model_id);
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_start",
|
||||
json!({
|
||||
"model": &self.model_id,
|
||||
"path": path,
|
||||
"auth_kind": auth_kind(&self.auth),
|
||||
"required_auth": format!("{:?}", self.scheme.required_auth()),
|
||||
"request_body_encoding": format!("{:?}", self.policy.request_body_encoding),
|
||||
"conversation_headers": format!("{:?}", self.policy.conversation_headers),
|
||||
"cache_key_present": request.cache_key.is_some(),
|
||||
"stream_open_timeout_ms": DEFAULT_STREAM_OPEN_TIMEOUT.as_millis() as u64,
|
||||
}),
|
||||
);
|
||||
|
||||
let url = self.build_url();
|
||||
let headers_started = Instant::now();
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_headers_start",
|
||||
json!({
|
||||
"auth_kind": auth_kind(&self.auth),
|
||||
"required_auth": format!("{:?}", self.scheme.required_auth()),
|
||||
}),
|
||||
);
|
||||
let mut headers = match self.build_headers().await {
|
||||
Ok(headers) => {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_headers_done",
|
||||
json!({
|
||||
"elapsed_ms": headers_started.elapsed().as_millis() as u64,
|
||||
"headers_len": headers.len(),
|
||||
"headers": request_header_diagnostics(&headers),
|
||||
}),
|
||||
);
|
||||
headers
|
||||
}
|
||||
Err(error) => {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_headers_error",
|
||||
json!({
|
||||
"elapsed_ms": headers_started.elapsed().as_millis() as u64,
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let stream_headers_started = Instant::now();
|
||||
if let Err(error) = self.apply_stream_headers(&mut headers, &request) {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_stream_headers_error",
|
||||
json!({
|
||||
"elapsed_ms": stream_headers_started.elapsed().as_millis() as u64,
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_stream_headers_done",
|
||||
json!({
|
||||
"elapsed_ms": stream_headers_started.elapsed().as_millis() as u64,
|
||||
"headers_len": headers.len(),
|
||||
"headers": request_header_diagnostics(&headers),
|
||||
}),
|
||||
);
|
||||
|
||||
let body_started = Instant::now();
|
||||
emit_transport_trace(&request, "transport_body_build_start", json!({}));
|
||||
let body = self
|
||||
.scheme
|
||||
.build_request_body(&self.model_id, &request, &self.capability);
|
||||
let body_shape = request_body_shape_payload(&body);
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_body_build_done",
|
||||
json!({
|
||||
"elapsed_ms": body_started.elapsed().as_millis() as u64,
|
||||
"body_kind": json_value_kind(&body),
|
||||
"request_shape": body_shape.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
let encode_started = Instant::now();
|
||||
let request_body = match self.encode_request_body(&body, &mut headers) {
|
||||
Ok(body) => body,
|
||||
Err(error) => {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_body_encode_error",
|
||||
json!({
|
||||
"elapsed_ms": encode_started.elapsed().as_millis() as u64,
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let final_request_headers = request_header_diagnostics(&headers);
|
||||
let body_compression = request_body.encoding().to_string();
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_body_encode_done",
|
||||
json!({
|
||||
"elapsed_ms": encode_started.elapsed().as_millis() as u64,
|
||||
"encoding": body_compression.as_str(),
|
||||
"body_compression": body_compression.as_str(),
|
||||
"raw_json_bytes": request_body.raw_json_bytes(),
|
||||
"wire_bytes": request_body.wire_bytes(),
|
||||
"request_shape": body_shape.clone(),
|
||||
"headers": final_request_headers.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
let builder = self.http_client.post(&url).headers(headers);
|
||||
let builder = match request_body {
|
||||
RequestBody::Json(body) => builder.json(&body),
|
||||
RequestBody::CompressedJson { bytes, .. } => builder.body(bytes),
|
||||
};
|
||||
|
||||
let send_started = Instant::now();
|
||||
emit_transport_trace(&request, "transport_http_send_start", json!({}));
|
||||
let response =
|
||||
match response_with_timeout(builder.send(), DEFAULT_STREAM_OPEN_TIMEOUT, "stream_open")
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let response_headers = response_header_diagnostics(response.headers());
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_http_headers_received",
|
||||
json!({
|
||||
"elapsed_ms": send_started.elapsed().as_millis() as u64,
|
||||
"status": response.status().as_u16(),
|
||||
"success": response.status().is_success(),
|
||||
"headers": response_headers,
|
||||
}),
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(error) => {
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_http_send_error",
|
||||
json!({
|
||||
"elapsed_ms": send_started.elapsed().as_millis() as u64,
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let retry_after_present = response.headers().get(RETRY_AFTER).is_some();
|
||||
let error = classify_error_response(response).await;
|
||||
let context_length_exceeded = is_context_length_exceeded(&error);
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_http_status_error",
|
||||
json!({
|
||||
"status": status,
|
||||
"retry_after_present": retry_after_present,
|
||||
"api_error_code": api_error_code(&error),
|
||||
"context_length_exceeded": context_length_exceeded,
|
||||
"provider_usage_absent": context_length_exceeded,
|
||||
"request_shape": body_shape.clone(),
|
||||
"request_headers": final_request_headers.clone(),
|
||||
"body_compression": body_compression.as_str(),
|
||||
}),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_stream_ready",
|
||||
json!({
|
||||
"elapsed_ms": total_started.elapsed().as_millis() as u64,
|
||||
}),
|
||||
);
|
||||
|
||||
let scheme = self.scheme.clone();
|
||||
let status = response.status().as_u16();
|
||||
let response_headers = response_header_diagnostics(response.headers());
|
||||
let transport_trace = request.transport_trace.clone();
|
||||
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
|
||||
let event_stream = byte_stream.eventsource();
|
||||
|
||||
// scheme 固有のパース状態をストリーム単位で保持する
|
||||
let mut state = <S::State as Default>::default();
|
||||
|
||||
let stream = event_stream
|
||||
.map(move |result| match result {
|
||||
Ok(frame) => match scheme.parse_sse(&frame.event, &frame.data, &mut state) {
|
||||
Ok(events) => Ok(events),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Err(e) => {
|
||||
let source = e.to_string();
|
||||
let message = sse_error_context(status, &response_headers, &source);
|
||||
if let Some(trace) = &transport_trace {
|
||||
trace.emit(
|
||||
"transport_sse_parse_error",
|
||||
json!({
|
||||
"status": status,
|
||||
"headers": response_headers.clone(),
|
||||
"error": source,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(ClientError::Sse(message))
|
||||
}
|
||||
})
|
||||
.map(|res| {
|
||||
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
|
||||
Ok(events) => Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
|
||||
Err(e) => Box::pin(futures::stream::once(async move { Err(e) })),
|
||||
};
|
||||
s
|
||||
})
|
||||
.flatten();
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestAuthProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for TestAuthProvider {
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError> {
|
||||
Ok(vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_static("Bearer test-token"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("chatgpt-account-id"),
|
||||
HeaderValue::from_static("account-1"),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestScheme;
|
||||
|
||||
impl Scheme for TestScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://example.test"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/responses".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
_capability: &ModelCapability,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"model": model_id,
|
||||
"input_len": request.items.len(),
|
||||
"prompt_cache_key": request.cache_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
_data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
ModelCapability::minimal()
|
||||
}
|
||||
}
|
||||
|
||||
fn transport(auth: ResolvedAuth) -> HttpTransport<TestScheme> {
|
||||
HttpTransport::new(
|
||||
TestScheme,
|
||||
"gpt-test",
|
||||
"https://example.test",
|
||||
auth,
|
||||
ModelCapability::minimal(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_error_context_includes_response_headers() {
|
||||
let headers = json!({
|
||||
"content_type": "application/octet-stream",
|
||||
"content_encoding": "gzip",
|
||||
"transfer_encoding": "chunked",
|
||||
"content_length": "123",
|
||||
});
|
||||
|
||||
let message = sse_error_context(200, &headers, "stream did not contain valid UTF-8");
|
||||
|
||||
assert!(message.contains("HTTP 200"));
|
||||
assert!(message.contains("stream did not contain valid UTF-8"));
|
||||
assert!(message.contains("content-type=application/octet-stream"));
|
||||
assert!(message.contains("content-encoding=gzip"));
|
||||
assert!(message.contains("transfer-encoding=chunked"));
|
||||
assert!(message.contains("content-length=123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_header_diagnostics_redacts_to_safe_header_subset() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("identity"));
|
||||
headers.insert("authorization", HeaderValue::from_static("Bearer secret"));
|
||||
|
||||
let diagnostics = response_header_diagnostics(&headers);
|
||||
|
||||
assert_eq!(diagnostics["content_type"], "text/event-stream");
|
||||
assert_eq!(diagnostics["content_encoding"], "identity");
|
||||
assert!(diagnostics.get("authorization").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_body_shape_counts_reasoning_encrypted_content() {
|
||||
let payload = request_body_shape_payload(&json!({
|
||||
"reasoning": { "summary": "auto" },
|
||||
"input": [
|
||||
{ "type": "message", "role": "user", "content": [] },
|
||||
{ "type": "reasoning", "encrypted_content": "abc", "summary": [] },
|
||||
{ "type": "reasoning", "encrypted_content": "defgh", "summary": [] }
|
||||
]
|
||||
}));
|
||||
assert_eq!(payload["items_len"], 3);
|
||||
assert_eq!(payload["reasoning_items"], 2);
|
||||
assert_eq!(payload["reasoning_encrypted_content_count"], 2);
|
||||
assert_eq!(payload["reasoning_encrypted_content_bytes"], 8);
|
||||
assert!(payload["items_json_bytes"].as_u64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_timeout_returns_retryable_lifecycle_timeout() {
|
||||
let err = response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
"stream_open",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(crate::llm_client::error::is_retryable(&err));
|
||||
assert!(matches!(
|
||||
err,
|
||||
ClientError::Timeout {
|
||||
phase: "stream_open",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_policy_adds_conversation_headers_and_zstd_body() {
|
||||
let transport = transport(ResolvedAuth::Custom(Arc::new(TestAuthProvider)))
|
||||
.with_transport_policy(TransportPolicy::openai_compatible_zstd());
|
||||
let request = Request::new().user("hello").cache_key("segment-123");
|
||||
let mut headers = transport.build_headers().await.unwrap();
|
||||
transport
|
||||
.apply_stream_headers(&mut headers, &request)
|
||||
.unwrap();
|
||||
let body = transport.scheme.build_request_body(
|
||||
&transport.model_id,
|
||||
&request,
|
||||
&transport.capability,
|
||||
);
|
||||
let encoded = transport.encode_request_body(&body, &mut headers).unwrap();
|
||||
|
||||
assert_eq!(headers.get(ACCEPT).unwrap(), "text/event-stream");
|
||||
assert_eq!(headers.get("session-id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get("thread-id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get("session_id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get("x-client-request-id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "zstd");
|
||||
|
||||
let diagnostics = request_header_diagnostics(&headers);
|
||||
assert_eq!(diagnostics["content_type"], "application/json");
|
||||
assert_eq!(diagnostics["content_encoding"], "zstd");
|
||||
assert_eq!(diagnostics["accept"], "text/event-stream");
|
||||
assert!(diagnostics["session_id_present"].as_bool().unwrap());
|
||||
assert!(diagnostics["thread_id_present"].as_bool().unwrap());
|
||||
assert!(diagnostics["legacy_session_id_present"].as_bool().unwrap());
|
||||
assert!(
|
||||
diagnostics["x_client_request_id_present"]
|
||||
.as_bool()
|
||||
.unwrap()
|
||||
);
|
||||
assert!(diagnostics["chatgpt_account_id_present"].as_bool().unwrap());
|
||||
|
||||
let RequestBody::CompressedJson {
|
||||
bytes: compressed,
|
||||
raw_json_bytes,
|
||||
} = encoded
|
||||
else {
|
||||
panic!("transport policy should zstd-compress request body");
|
||||
};
|
||||
assert!(raw_json_bytes > 0);
|
||||
let decoded = zstd::stream::decode_all(std::io::Cursor::new(compressed)).unwrap();
|
||||
let decoded: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
|
||||
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standard_policy_does_not_get_conversation_headers_or_compression() {
|
||||
let transport = transport(ResolvedAuth::ApiKey("api-key".to_string()));
|
||||
let request = Request::new().user("hello").cache_key("segment-123");
|
||||
let mut headers = transport.build_headers().await.unwrap();
|
||||
transport
|
||||
.apply_stream_headers(&mut headers, &request)
|
||||
.unwrap();
|
||||
let body = transport.scheme.build_request_body(
|
||||
&transport.model_id,
|
||||
&request,
|
||||
&transport.capability,
|
||||
);
|
||||
let encoded = transport.encode_request_body(&body, &mut headers).unwrap();
|
||||
|
||||
assert_eq!(headers.get(ACCEPT).unwrap(), "text/event-stream");
|
||||
assert!(headers.get("session-id").is_none());
|
||||
assert!(headers.get("thread-id").is_none());
|
||||
assert!(headers.get("session_id").is_none());
|
||||
assert!(headers.get("x-client-request-id").is_none());
|
||||
assert!(headers.get(CONTENT_ENCODING).is_none());
|
||||
|
||||
let RequestBody::Json(decoded) = encoded else {
|
||||
panic!("standard transport policy should keep request body as JSON");
|
||||
};
|
||||
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
//! LLM Client Common Types
|
||||
//!
|
||||
//! Core conversation types for LLM interaction.
|
||||
//! The core abstraction is `Item` which represents different types of conversation elements:
|
||||
//! - Message items (user/assistant messages with content parts)
|
||||
//! - ToolCall items (tool invocations)
|
||||
//! - ToolResult items (tool results)
|
||||
//! - Reasoning items (extended thinking)
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use crate::tool::Attachment;
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
pub(crate) fn image_data_url(media_type: &str, data: &[u8]) -> String {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
format!("data:{media_type};base64,{encoded}")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Item - The core unit of conversation
|
||||
// ============================================================================
|
||||
|
||||
/// Item ID type for tracking items in a conversation
|
||||
pub type ItemId = String;
|
||||
|
||||
/// Call ID type for linking function calls to their outputs
|
||||
pub type CallId = String;
|
||||
|
||||
/// Callback sink for request-local transport lifecycle diagnostics.
|
||||
///
|
||||
/// This is carried on [`Request`] so generic [`crate::llm_client::LlmClient`]
|
||||
/// implementations can emit fine-grained transport milestones without widening
|
||||
/// the trait method signature. The callback must never receive request body
|
||||
/// contents or secret header values.
|
||||
#[derive(Clone)]
|
||||
pub struct RequestTrace {
|
||||
callback: Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl RequestTrace {
|
||||
pub fn new(callback: impl Fn(&str, serde_json::Value) + Send + Sync + 'static) -> Self {
|
||||
Self {
|
||||
callback: Arc::new(callback),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit(&self, label: &str, data: serde_json::Value) {
|
||||
(self.callback)(label, data);
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RequestTrace {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("RequestTrace").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversation item - the primary unit of conversation history
|
||||
///
|
||||
/// Items represent discrete elements in a conversation. Tool calls and reasoning
|
||||
/// are first-class items rather than parts of messages.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use agen::Item;
|
||||
///
|
||||
/// let user = Item::user_message("Hello!");
|
||||
/// let assistant = Item::assistant_message("Hi there!");
|
||||
/// let call = Item::tool_call("call_123", "get_weather", json!({"city": "Tokyo"}));
|
||||
/// let result = Item::tool_result("call_123", "Sunny, 25°C");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Item {
|
||||
/// User or assistant message with content parts
|
||||
Message {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Message role
|
||||
role: Role,
|
||||
/// Content parts
|
||||
content: Vec<ContentPart>,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
|
||||
/// Tool call from the assistant
|
||||
ToolCall {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Call ID for linking to result
|
||||
call_id: CallId,
|
||||
/// Tool name
|
||||
name: String,
|
||||
/// Tool arguments as JSON string
|
||||
arguments: String,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
|
||||
/// Tool call result
|
||||
ToolResult {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Call ID linking to the tool call
|
||||
call_id: CallId,
|
||||
/// Short summary (always kept in history, survives pruning)
|
||||
summary: String,
|
||||
/// Detailed output (removed by pruning when old enough)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
/// Whether the tool result represents an execution error.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
/// Durable binary details (removed with `content` by normal pruning).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<Attachment>,
|
||||
},
|
||||
|
||||
/// Reasoning/thinking item
|
||||
Reasoning {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Reasoning text(reasoning body, `reasoning_text.delta` の累積)
|
||||
text: String,
|
||||
/// Reasoning summary(OpenAI Responses の `summary_text[]` を格納。
|
||||
/// 他 scheme は空)
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
summary: Vec<String>,
|
||||
/// サーバから返された暗号化済み reasoning blob。ZDR / `store=false`
|
||||
/// 運用で stateless に再送するときそのまま添える必要がある。
|
||||
/// Anthropic の `redacted_thinking.data` もここに格納する。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
encrypted_content: Option<String>,
|
||||
/// Anthropic extended thinking の `signature`。新世代 Claude
|
||||
/// (Opus 4.5+/Sonnet 4.6+) では同一論理ターン内の `thinking`
|
||||
/// ブロックを送り返す際に必須。改ざん検知に使われる。他 scheme
|
||||
/// では `None`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Item {
|
||||
// ========================================================================
|
||||
// Message constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a system message item with text content.
|
||||
///
|
||||
/// System items in history are sent as `role: "system"` on OpenAI,
|
||||
/// and as `role: "user"` on Anthropic/Gemini (which lack a system
|
||||
/// role in conversation items).
|
||||
pub fn system_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::System,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message item with text content
|
||||
pub fn user_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::User,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message item with multiple content parts
|
||||
pub fn user_message_parts(parts: Vec<ContentPart>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::User,
|
||||
content: parts,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message item with text content
|
||||
pub fn assistant_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message item with multiple content parts
|
||||
pub fn assistant_message_parts(parts: Vec<ContentPart>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::Assistant,
|
||||
content: parts,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Tool call constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a tool call item
|
||||
pub fn tool_call(
|
||||
call_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
arguments: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::ToolCall {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
name: name.into(),
|
||||
arguments: arguments.into(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool call item from a JSON value
|
||||
pub fn tool_call_json(
|
||||
call_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
arguments: serde_json::Value,
|
||||
) -> Self {
|
||||
Self::tool_call(call_id, name, arguments.to_string())
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary only (no content).
|
||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, false)
|
||||
}
|
||||
|
||||
/// Create an error tool result item with summary only (no content).
|
||||
pub fn tool_result_error(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, true)
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary, optional content, and error flag.
|
||||
pub fn tool_result_item(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
|
||||
}
|
||||
|
||||
/// Create a tool result item with durable, prunable structured attachments.
|
||||
pub fn tool_result_item_with_attachments(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
attachments: Vec<Attachment>,
|
||||
) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content,
|
||||
is_error,
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary and content.
|
||||
pub fn tool_result_with_content(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::tool_result_item(call_id, summary, Some(content.into()), false)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Reasoning constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a reasoning item
|
||||
pub fn reasoning(text: impl Into<String>) -> Self {
|
||||
Self::Reasoning {
|
||||
id: None,
|
||||
text: text.into(),
|
||||
summary: Vec::new(),
|
||||
encrypted_content: None,
|
||||
signature: None,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set reasoning summary on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_reasoning_summary(mut self, new_summary: Vec<String>) -> Self {
|
||||
if let Self::Reasoning { summary, .. } = &mut self {
|
||||
*summary = new_summary;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set `encrypted_content` on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_encrypted_content(mut self, content: impl Into<String>) -> Self {
|
||||
if let Self::Reasoning {
|
||||
encrypted_content, ..
|
||||
} = &mut self
|
||||
{
|
||||
*encrypted_content = Some(content.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set Anthropic `signature` on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_signature(mut self, sig: impl Into<String>) -> Self {
|
||||
if let Self::Reasoning { signature, .. } = &mut self {
|
||||
*signature = Some(sig.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Builder methods
|
||||
// ========================================================================
|
||||
|
||||
/// Set the item ID
|
||||
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::ToolCall { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::ToolResult { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::Reasoning { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the item status
|
||||
pub fn with_status(mut self, new_status: ItemStatus) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { status, .. } => *status = Some(new_status),
|
||||
Self::ToolCall { status, .. } => *status = Some(new_status),
|
||||
Self::ToolResult { .. } => {} // Result items don't have status
|
||||
Self::Reasoning { status, .. } => *status = Some(new_status),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Accessors
|
||||
// ========================================================================
|
||||
|
||||
/// Get the item ID if set
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Message { id, .. } => id.as_deref(),
|
||||
Self::ToolCall { id, .. } => id.as_deref(),
|
||||
Self::ToolResult { id, .. } => id.as_deref(),
|
||||
Self::Reasoning { id, .. } => id.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the item type as a string
|
||||
pub fn item_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Message { .. } => "message",
|
||||
Self::ToolCall { .. } => "tool_call",
|
||||
Self::ToolResult { .. } => "tool_result",
|
||||
Self::Reasoning { .. } => "reasoning",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a user message
|
||||
pub fn is_user_message(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Message {
|
||||
role: Role::User,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is an assistant message
|
||||
pub fn is_assistant_message(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Message {
|
||||
role: Role::Assistant,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is a tool call
|
||||
pub fn is_tool_call(&self) -> bool {
|
||||
matches!(self, Self::ToolCall { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a tool result
|
||||
pub fn is_tool_result(&self) -> bool {
|
||||
matches!(self, Self::ToolResult { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a reasoning item
|
||||
pub fn is_reasoning(&self) -> bool {
|
||||
matches!(self, Self::Reasoning { .. })
|
||||
}
|
||||
|
||||
/// Get text content if this is a simple text message
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Message { content, .. } if content.len() == 1 => match &content[0] {
|
||||
ContentPart::Text { text } => Some(text),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a ToolCall `arguments` string into a JSON object.
|
||||
///
|
||||
/// Tool call arguments must be a JSON object at the provider API level
|
||||
/// (Anthropic rejects non-object `tool_use.input`). This helper normalizes
|
||||
/// anything that is not a JSON object — empty string, the literal `"null"`,
|
||||
/// arrays, scalars, or parse failures — to an empty object `{}`.
|
||||
pub fn parse_tool_arguments(arguments: &str) -> serde_json::Value {
|
||||
match serde_json::from_str::<serde_json::Value>(arguments) {
|
||||
Ok(value) if value.is_object() => value,
|
||||
_ => serde_json::Value::Object(serde_json::Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Parts - Components within message items
|
||||
// ============================================================================
|
||||
|
||||
/// Content part within a message item
|
||||
///
|
||||
/// Text content is role-agnostic; the containing Item's Role determines
|
||||
/// whether it's user input or assistant output.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentPart {
|
||||
/// Text content
|
||||
Text {
|
||||
/// The text content
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Refusal content (for assistant messages)
|
||||
Refusal {
|
||||
/// The refusal message
|
||||
refusal: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContentPart {
|
||||
/// Create a text part
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self::Text { text: text.into() }
|
||||
}
|
||||
|
||||
/// Create a refusal part
|
||||
pub fn refusal(refusal: impl Into<String>) -> Self {
|
||||
Self::Refusal {
|
||||
refusal: refusal.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a textual projection of the content part.
|
||||
pub fn as_text(&self) -> &str {
|
||||
match self {
|
||||
Self::Text { text } => text,
|
||||
Self::Refusal { refusal } => refusal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Role and Status
|
||||
// ============================================================================
|
||||
|
||||
/// Message role
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
/// User
|
||||
User,
|
||||
/// Assistant
|
||||
Assistant,
|
||||
/// System (for system prompts, not typically used in items)
|
||||
System,
|
||||
}
|
||||
|
||||
/// Item status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ItemStatus {
|
||||
/// Item is being generated
|
||||
InProgress,
|
||||
/// Item completed successfully
|
||||
Completed,
|
||||
/// Item was truncated (e.g., max tokens)
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Types
|
||||
// ============================================================================
|
||||
|
||||
/// LLM Request
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Request {
|
||||
/// System prompt (instructions)
|
||||
pub system_prompt: Option<String>,
|
||||
/// Input items (conversation history)
|
||||
pub items: Vec<Item>,
|
||||
/// Tool definitions
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
/// Request configuration
|
||||
pub config: RequestConfig,
|
||||
/// Index into `items` marking the end of a stable, cacheable prefix.
|
||||
///
|
||||
/// Higher layers that know about durable prefix boundaries (e.g. a
|
||||
/// post-compaction summary) set this so that caching-aware providers
|
||||
/// (Anthropic today) can place a long-lived cache breakpoint there.
|
||||
/// Providers without prompt caching ignore the field.
|
||||
pub cache_anchor: Option<usize>,
|
||||
/// 会話単位の安定キー。`prompt_cache_key` として送られる
|
||||
/// (OpenAI Responses)。明示キーを必要とする backend では、
|
||||
/// 呼び出し側が安定した conversation identifier を渡す。
|
||||
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
|
||||
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
||||
/// `prompt_cache_key` を持たない provider は無視する。
|
||||
pub cache_key: Option<String>,
|
||||
/// Request-local diagnostics sink for transport lifecycle tracing.
|
||||
#[doc(hidden)]
|
||||
pub transport_trace: Option<RequestTrace>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Create a new empty request
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the system prompt
|
||||
pub fn system(mut self, prompt: impl Into<String>) -> Self {
|
||||
self.system_prompt = Some(prompt.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a user message
|
||||
pub fn user(mut self, content: impl Into<String>) -> Self {
|
||||
self.items.push(Item::user_message(content));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an assistant message
|
||||
pub fn assistant(mut self, content: impl Into<String>) -> Self {
|
||||
self.items.push(Item::assistant_message(content));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an item
|
||||
pub fn item(mut self, item: Item) -> Self {
|
||||
self.items.push(item);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add multiple items
|
||||
pub fn items(mut self, items: impl IntoIterator<Item = Item>) -> Self {
|
||||
self.items.extend(items);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a tool definition
|
||||
pub fn tool(mut self, tool: ToolDefinition) -> Self {
|
||||
self.tools.push(tool);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the request config
|
||||
pub fn config(mut self, config: RequestConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a request-local transport trace callback.
|
||||
pub fn transport_trace(
|
||||
mut self,
|
||||
callback: impl Fn(&str, serde_json::Value) + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
self.transport_trace = Some(RequestTrace::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set max tokens
|
||||
pub fn max_tokens(mut self, max_tokens: u32) -> Self {
|
||||
self.config.max_tokens = Some(max_tokens);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set temperature
|
||||
pub fn temperature(mut self, temperature: f32) -> Self {
|
||||
self.config.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_p
|
||||
pub fn top_p(mut self, top_p: f32) -> Self {
|
||||
self.config.top_p = Some(top_p);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_k
|
||||
pub fn top_k(mut self, top_k: u32) -> Self {
|
||||
self.config.top_k = Some(top_k);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a stop sequence
|
||||
pub fn stop_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.config.stop_sequences.push(sequence.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the conversation cache key.
|
||||
///
|
||||
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
|
||||
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
|
||||
self.cache_key = Some(key.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Definition
|
||||
// ============================================================================
|
||||
|
||||
/// Tool (function) definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
/// Tool name
|
||||
pub name: String,
|
||||
/// Tool description
|
||||
pub description: Option<String>,
|
||||
/// Input schema (JSON Schema)
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
/// Create a new tool definition
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the description
|
||||
pub fn description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = Some(desc.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the input schema
|
||||
pub fn input_schema(mut self, schema: serde_json::Value) -> Self {
|
||||
self.input_schema = schema;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Config
|
||||
// ============================================================================
|
||||
|
||||
/// Request configuration
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RequestConfig {
|
||||
/// Maximum tokens to generate
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Temperature (randomness)
|
||||
pub temperature: Option<f32>,
|
||||
/// Top P (nucleus sampling)
|
||||
pub top_p: Option<f32>,
|
||||
/// Top K
|
||||
pub top_k: Option<u32>,
|
||||
/// Stop sequences
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// Reasoning / extended-thinking 制御(共通型、scheme 側で各社形式に投影)。
|
||||
///
|
||||
/// `None` のときは何も送らない。`Some` でも scheme の
|
||||
/// `ModelCapability::reasoning` が `None` なら無視される。
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<crate::llm_client::capability::ReasoningControl>,
|
||||
}
|
||||
|
||||
impl RequestConfig {
|
||||
/// Create a new default config
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set max tokens
|
||||
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
|
||||
self.max_tokens = Some(max_tokens);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set temperature
|
||||
pub fn with_temperature(mut self, temperature: f32) -> Self {
|
||||
self.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_p
|
||||
pub fn with_top_p(mut self, top_p: f32) -> Self {
|
||||
self.top_p = Some(top_p);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_k
|
||||
pub fn with_top_k(mut self, top_k: u32) -> Self {
|
||||
self.top_k = Some(top_k);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a stop sequence
|
||||
pub fn with_stop_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.stop_sequences.push(sequence.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parse_tool_arguments_tests {
|
||||
use super::parse_tool_arguments;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn empty_object() -> Value {
|
||||
Value::Object(serde_json::Map::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments(""), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_null_normalizes_to_object() {
|
||||
// 既存セッションに残っている "null" が resume 時に復旧できること
|
||||
assert_eq!(parse_tool_arguments("null"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("[1, 2, 3]"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("42"), empty_object());
|
||||
assert_eq!(parse_tool_arguments("\"str\""), empty_object());
|
||||
assert_eq!(parse_tool_arguments("true"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("{not json"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_object_passes_through() {
|
||||
assert_eq!(
|
||||
parse_tool_arguments(r#"{"city":"Tokyo","days":3}"#),
|
||||
json!({"city": "Tokyo", "days": 3}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_passes_through() {
|
||||
assert_eq!(parse_tool_arguments("{}"), empty_object());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user