llm-model-configの実装
This commit is contained in:
@@ -20,9 +20,35 @@ mod recorder;
|
||||
mod scenarios;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use llm_worker::llm_client::providers::anthropic::AnthropicClient;
|
||||
use llm_worker::llm_client::providers::gemini::GeminiClient;
|
||||
use llm_worker::llm_client::providers::openai::OpenAIClient;
|
||||
use llm_worker::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
use llm_worker::llm_client::scheme::{
|
||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||
};
|
||||
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
|
||||
/// 既定の capability: fixture 記録には cache_control を付けない
|
||||
/// (既知モデルの静的テーブルを経由すると scheme 毎に自動設定される)。
|
||||
fn fallback_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_transport<S: Scheme>(
|
||||
scheme: S,
|
||||
model: &str,
|
||||
auth: ResolvedAuth,
|
||||
) -> HttpTransport<S> {
|
||||
let cap = scheme.capability_for(model).unwrap_or_else(fallback_capability);
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
HttpTransport::new(scheme, model.to_string(), base_url, auth, cap)
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
@@ -60,7 +86,11 @@ async fn run_scenario_with_anthropic(
|
||||
let api_key = std::env::var("ANTHROPIC_API_KEY")
|
||||
.expect("ANTHROPIC_API_KEY environment variable must be set");
|
||||
let model = model.as_deref().unwrap_or("claude-sonnet-4-20250514");
|
||||
let client = AnthropicClient::new(&api_key, model);
|
||||
let client = make_transport(
|
||||
AnthropicScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
);
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
@@ -82,7 +112,7 @@ async fn run_scenario_with_openai(
|
||||
let api_key =
|
||||
std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY environment variable must be set");
|
||||
let model = model.as_deref().unwrap_or("gpt-4o");
|
||||
let client = OpenAIClient::new(&api_key, model);
|
||||
let client = make_transport(OpenAIScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
@@ -101,10 +131,15 @@ async fn run_scenario_with_ollama(
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use llm_worker::llm_client::providers::ollama::OllamaClient;
|
||||
// Ollama typically runs local, no key needed or placeholder
|
||||
let model = model.as_deref().unwrap_or("llama3"); // default example
|
||||
let client = OllamaClient::new(model); // base_url placeholder, handled by client default
|
||||
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
|
||||
let model = model.as_deref().unwrap_or("llama3");
|
||||
let client = HttpTransport::new(
|
||||
AnthropicScheme::new(),
|
||||
model.to_string(),
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
fallback_capability(),
|
||||
);
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
@@ -126,7 +161,7 @@ async fn run_scenario_with_gemini(
|
||||
let api_key =
|
||||
std::env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY environment variable must be set");
|
||||
let model = model.as_deref().unwrap_or("gemini-2.0-flash");
|
||||
let client = GeminiClient::new(&api_key, model);
|
||||
let client = make_transport(GeminiScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
//!
|
||||
//! Example of cancelling from another thread during streaming
|
||||
|
||||
use llm_worker::llm_client::providers::anthropic::AnthropicClient;
|
||||
use llm_worker::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
use llm_worker::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use llm_worker::{Worker, WorkerResult};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -22,7 +26,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_key =
|
||||
std::env::var("ANTHROPIC_API_KEY").expect("ANTHROPIC_API_KEY environment variable not set");
|
||||
|
||||
let client = AnthropicClient::new(&api_key, "claude-sonnet-4-20250514");
|
||||
let scheme = AnthropicScheme::new();
|
||||
let model = "claude-sonnet-4-20250514".to_string();
|
||||
let cap = scheme.capability_for(&model).unwrap_or(ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
});
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||
let worker = Worker::new(client);
|
||||
|
||||
println!("🚀 Starting Worker...");
|
||||
|
||||
@@ -44,10 +44,11 @@ use llm_worker::{
|
||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||
llm_client::{
|
||||
LlmClient,
|
||||
providers::{
|
||||
anthropic::AnthropicClient, gemini::GeminiClient, ollama::OllamaClient,
|
||||
openai::OpenAIClient,
|
||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||
scheme::{
|
||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||
},
|
||||
transport::{HttpTransport, ResolvedAuth},
|
||||
},
|
||||
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
||||
};
|
||||
@@ -327,6 +328,28 @@ fn get_api_key(args: &Args) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// Create client based on provider
|
||||
fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transport<S: Scheme>(
|
||||
scheme: S,
|
||||
model: String,
|
||||
auth: ResolvedAuth,
|
||||
) -> Box<dyn LlmClient> {
|
||||
let cap = scheme
|
||||
.capability_for(&model)
|
||||
.unwrap_or_else(default_capability);
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
Box::new(HttpTransport::new(scheme, model, base_url, auth, cap))
|
||||
}
|
||||
|
||||
fn create_client(args: &Args) -> Result<Box<dyn LlmClient>, String> {
|
||||
let model = args
|
||||
.model
|
||||
@@ -336,21 +359,32 @@ fn create_client(args: &Args) -> Result<Box<dyn LlmClient>, String> {
|
||||
let api_key = get_api_key(args)?;
|
||||
|
||||
match args.provider {
|
||||
Provider::Anthropic => {
|
||||
let client = AnthropicClient::new(&api_key, &model);
|
||||
Ok(Box::new(client))
|
||||
}
|
||||
Provider::Gemini => {
|
||||
let client = GeminiClient::new(&api_key, &model);
|
||||
Ok(Box::new(client))
|
||||
}
|
||||
Provider::Openai => {
|
||||
let client = OpenAIClient::new(&api_key, &model);
|
||||
Ok(Box::new(client))
|
||||
}
|
||||
Provider::Anthropic => Ok(build_transport(
|
||||
AnthropicScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Gemini => Ok(build_transport(
|
||||
GeminiScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Openai => Ok(build_transport(
|
||||
OpenAIScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Ollama => {
|
||||
let client = OllamaClient::new(&model);
|
||||
Ok(Box::new(client))
|
||||
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
|
||||
let scheme = AnthropicScheme::new();
|
||||
let cap = default_capability();
|
||||
Ok(Box::new(HttpTransport::new(
|
||||
scheme,
|
||||
model,
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
cap,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//! `Scheme` 実装と通信層が要求する認証要件。
|
||||
//!
|
||||
//! マニフェスト側の型(`ModelConfig` / `SchemeKind` / `AuthRef`)は
|
||||
//! `crates/manifest` に置き、llm-worker はそれを知らずに済む。
|
||||
//! `AuthRequirement` は scheme が宣言する「この scheme はどんな認証を
|
||||
//! 期待するか」のランタイム記述で、manifest 側の `AuthRef` との
|
||||
//! 照合(`AuthRef → ResolvedAuth` 変換の適否)は `crates/provider`
|
||||
//! で行う。
|
||||
|
||||
/// `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 },
|
||||
/// 複合ヘッダ(Codex OAuth 等、`crates/provider` 側で解決)
|
||||
Custom,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! モデル能力メタデータ
|
||||
//!
|
||||
//! `ModelCapability` はモデルが持つ機能差を表現する。scheme は同じでも
|
||||
//! モデルごとに reasoning 可否や prompt caching 方式が違うため、scheme
|
||||
//! から分離して保持する。
|
||||
//!
|
||||
//! 値の供給経路は 2 通り:
|
||||
//! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル)
|
||||
//! 2. `ModelConfig::capability` での明示 override(未知モデル、または上書き)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// モデル能力メタデータ
|
||||
#[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 側で各社形式に投影)
|
||||
///
|
||||
/// `effort` / `budget_tokens` はユーザー設定から任意で渡される。Scheme
|
||||
/// 側は自身の `ReasoningSupport` に応じて片方だけ使う。両方が宣言
|
||||
/// されている場合の優先順位は scheme 実装が決める。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ReasoningControl {
|
||||
#[serde(default)]
|
||||
pub effort: Option<ReasoningEffort>,
|
||||
#[serde(default)]
|
||||
pub budget_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReasoningEffort {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
@@ -16,14 +16,18 @@
|
||||
//! - `providers`: プロバイダ固有のクライアント実装
|
||||
//! - `scheme`: APIスキーマ(リクエスト/レスポンス変換)
|
||||
|
||||
pub mod auth;
|
||||
pub mod capability;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod types;
|
||||
|
||||
pub mod providers;
|
||||
pub mod scheme;
|
||||
pub mod transport;
|
||||
|
||||
pub use auth::*;
|
||||
pub use capability::*;
|
||||
pub use client::*;
|
||||
pub use error::*;
|
||||
pub use event::*;
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
//! Anthropic プロバイダ実装
|
||||
//!
|
||||
//! Anthropic Messages APIと通信し、Eventストリームを出力
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError, LlmClient, Request, event::Event, scheme::anthropic::AnthropicScheme,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt, TryStreamExt, future::ready};
|
||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
|
||||
/// Anthropic クライアント
|
||||
#[derive(Clone)]
|
||||
pub struct AnthropicClient {
|
||||
/// HTTPクライアント
|
||||
http_client: reqwest::Client,
|
||||
/// APIキー
|
||||
api_key: String,
|
||||
/// モデル名
|
||||
model: String,
|
||||
/// スキーマ
|
||||
scheme: AnthropicScheme,
|
||||
/// ベースURL
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl AnthropicClient {
|
||||
/// 新しいAnthropicクライアントを作成
|
||||
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http_client: reqwest::Client::new(),
|
||||
api_key: api_key.into(),
|
||||
model: model.into(),
|
||||
scheme: AnthropicScheme::default(),
|
||||
base_url: "https://api.anthropic.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// カスタムHTTPクライアントを設定
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// スキーマを設定
|
||||
pub fn with_scheme(mut self, scheme: AnthropicScheme) -> Self {
|
||||
self.scheme = scheme;
|
||||
self
|
||||
}
|
||||
|
||||
/// ベースURLを設定
|
||||
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.base_url = url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// リクエストヘッダーを構築
|
||||
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(
|
||||
"x-api-key",
|
||||
HeaderValue::from_str(&self.api_key)
|
||||
.map_err(|e| ClientError::Config(format!("Invalid API key: {}", e)))?,
|
||||
);
|
||||
headers.insert(
|
||||
"anthropic-version",
|
||||
HeaderValue::from_str(&self.scheme.api_version)
|
||||
.map_err(|e| ClientError::Config(format!("Invalid API version: {}", e)))?,
|
||||
);
|
||||
|
||||
// 細粒度ツールストリーミングを有効にする場合
|
||||
if self.scheme.fine_grained_tool_streaming {
|
||||
headers.insert(
|
||||
"anthropic-beta",
|
||||
HeaderValue::from_static("fine-grained-tool-streaming-2025-05-14"),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for AnthropicClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
let url = format!("{}/v1/messages", self.base_url);
|
||||
let headers = self.build_headers()?;
|
||||
let body = self.scheme.build_request(&self.model, &request);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// エラーレスポンスをチェック
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
|
||||
// JSONでエラーをパースしてみる
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let error = json.get("error").unwrap_or(&json);
|
||||
let code = 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();
|
||||
return Err(ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return Err(ClientError::Api {
|
||||
status: Some(status),
|
||||
code: None,
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
|
||||
// SSEストリームを構築
|
||||
let scheme = self.scheme.clone();
|
||||
let byte_stream = response
|
||||
.bytes_stream()
|
||||
.map_err(|e| std::io::Error::other(e));
|
||||
let event_stream = byte_stream.eventsource();
|
||||
|
||||
// AnthropicはBlockStopイベントに正しいblock_typeを含まないため、
|
||||
// クライアント側で状態を追跡して補完する
|
||||
let mut current_block_type = None;
|
||||
|
||||
let stream = event_stream.filter_map(move |result| {
|
||||
ready(match result {
|
||||
Ok(event) => {
|
||||
// SSEイベントをパース
|
||||
match scheme.parse_event(&event.event, &event.data) {
|
||||
Ok(Some(mut evt)) => {
|
||||
// ブロックタイプの追跡と修正
|
||||
match &evt {
|
||||
Event::BlockStart(start) => {
|
||||
current_block_type = Some(start.block_type);
|
||||
}
|
||||
Event::BlockStop(stop) => {
|
||||
if let Some(block_type) = current_block_type.take() {
|
||||
// 正しいブロックタイプで上書き
|
||||
// (Event::BlockStopの中身を置換)
|
||||
evt =
|
||||
Event::BlockStop(crate::llm_client::event::BlockStop {
|
||||
block_type,
|
||||
..stop.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(Ok(evt))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => Some(Err(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => Some(Err(ClientError::Sse(e.to_string()))),
|
||||
})
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let client = AnthropicClient::new("test-key", "claude-sonnet-4-20250514");
|
||||
assert_eq!(client.model, "claude-sonnet-4-20250514");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_headers() {
|
||||
let client = AnthropicClient::new("test-key", "claude-sonnet-4-20250514");
|
||||
let headers = client.build_headers().unwrap();
|
||||
|
||||
assert!(headers.contains_key("x-api-key"));
|
||||
assert!(headers.contains_key("anthropic-version"));
|
||||
assert!(headers.contains_key("anthropic-beta"));
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
//! Gemini プロバイダ実装
|
||||
//!
|
||||
//! Google Gemini APIと通信し、Eventストリームを出力
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError, LlmClient, Request, event::Event, scheme::gemini::GeminiScheme,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
|
||||
/// Gemini クライアント
|
||||
#[derive(Clone)]
|
||||
pub struct GeminiClient {
|
||||
/// HTTPクライアント
|
||||
http_client: reqwest::Client,
|
||||
/// APIキー
|
||||
api_key: String,
|
||||
/// モデル名
|
||||
model: String,
|
||||
/// スキーマ
|
||||
scheme: GeminiScheme,
|
||||
/// ベースURL
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl GeminiClient {
|
||||
/// 新しいGeminiクライアントを作成
|
||||
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http_client: reqwest::Client::new(),
|
||||
api_key: api_key.into(),
|
||||
model: model.into(),
|
||||
scheme: GeminiScheme::default(),
|
||||
base_url: "https://generativelanguage.googleapis.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// カスタムHTTPクライアントを設定
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// スキーマを設定
|
||||
pub fn with_scheme(mut self, scheme: GeminiScheme) -> Self {
|
||||
self.scheme = scheme;
|
||||
self
|
||||
}
|
||||
|
||||
/// ベースURLを設定
|
||||
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.base_url = url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// リクエストヘッダーを構築
|
||||
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for GeminiClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
// URL構築: base_url/v1beta/models/{model}:streamGenerateContent?alt=sse&key={api_key}
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||
self.base_url, self.model, self.api_key
|
||||
);
|
||||
|
||||
let headers = self.build_headers()?;
|
||||
let body = self.scheme.build_request(&request);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// エラーレスポンスをチェック
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
|
||||
// JSONでエラーをパースしてみる
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
// Gemini error format: { "error": { "code": xxx, "message": "...", "status": "..." } }
|
||||
let error = json.get("error").unwrap_or(&json);
|
||||
let code = error
|
||||
.get("status")
|
||||
.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();
|
||||
return Err(ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return Err(ClientError::Api {
|
||||
status: Some(status),
|
||||
code: None,
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
|
||||
// SSEストリームを構築
|
||||
let scheme = self.scheme.clone();
|
||||
let byte_stream = response
|
||||
.bytes_stream()
|
||||
.map_err(|e| std::io::Error::other(e));
|
||||
let event_stream = byte_stream.eventsource();
|
||||
|
||||
let stream = event_stream
|
||||
.map(move |result| {
|
||||
match result {
|
||||
Ok(event) => {
|
||||
// SSEイベントをパース
|
||||
// Geminiは "data: {...}" 形式で送る
|
||||
match scheme.parse_event(&event.data) {
|
||||
Ok(Some(events)) => Ok(Some(events)),
|
||||
Ok(None) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ClientError::Sse(e.to_string())),
|
||||
}
|
||||
})
|
||||
// flatten Option<Vec<Event>> stream to Stream<Event>
|
||||
.map(|res| {
|
||||
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
|
||||
Ok(Some(events)) => Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
|
||||
Ok(None) => Box::pin(futures::stream::empty()),
|
||||
Err(e) => Box::pin(futures::stream::once(async move { Err(e) })),
|
||||
};
|
||||
s
|
||||
})
|
||||
.flatten();
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let client = GeminiClient::new("test-key", "gemini-2.0-flash");
|
||||
assert_eq!(client.model, "gemini-2.0-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_headers() {
|
||||
let client = GeminiClient::new("test-key", "gemini-2.0-flash");
|
||||
let headers = client.build_headers().unwrap();
|
||||
|
||||
assert!(headers.contains_key("content-type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_base_url() {
|
||||
let client = GeminiClient::new("test-key", "gemini-2.0-flash")
|
||||
.with_base_url("https://custom.api.example.com");
|
||||
assert_eq!(client.base_url, "https://custom.api.example.com");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//! プロバイダ実装
|
||||
//!
|
||||
//! 各プロバイダ固有のHTTPクライアント実装
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod gemini;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Ollama プロバイダ実装
|
||||
//!
|
||||
//! OllamaはOpenAI互換APIを提供するため、OpenAIクライアントと互換性がある。
|
||||
//! デフォルトのベースURLと認証設定が異なる。
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError, LlmClient, Request, event::Event, providers::openai::OpenAIClient,
|
||||
scheme::openai::OpenAIScheme,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
|
||||
/// Ollama クライアント
|
||||
///
|
||||
/// 内部的にOpenAIClientを使用するラッパー、もしくはOpenAIClientと同様の実装を持つ。
|
||||
/// ここではOpenAIClient構成をカスタマイズして提供する。
|
||||
#[derive(Clone)]
|
||||
pub struct OllamaClient {
|
||||
inner: OpenAIClient,
|
||||
}
|
||||
|
||||
impl OllamaClient {
|
||||
/// 新しいOllamaクライアントを作成
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
// Ollama usually runs on localhost:11434/v1
|
||||
// API key is "ollama" or ignored
|
||||
let base_url = "http://localhost:11434";
|
||||
|
||||
let scheme = OpenAIScheme::new().with_legacy_max_tokens(true);
|
||||
|
||||
let client = OpenAIClient::new("ollama", model)
|
||||
.with_base_url(base_url)
|
||||
.with_scheme(scheme);
|
||||
// Currently OpenAIScheme sets include_usage: true. Ollama supports checks?
|
||||
// Assuming Ollama modern versions support usage.
|
||||
|
||||
Self { inner: client }
|
||||
}
|
||||
|
||||
/// ベースURLを設定
|
||||
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.inner = self.inner.with_base_url(url);
|
||||
self
|
||||
}
|
||||
|
||||
/// カスタムHTTPクライアントを設定
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.inner = self.inner.with_http_client(client);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for OllamaClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
self.inner.stream(request).await
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! OpenAI プロバイダ実装
|
||||
//!
|
||||
//! OpenAI Chat Completions APIと通信し、Eventストリームを出力
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, event::Event,
|
||||
scheme::openai::OpenAIScheme,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
|
||||
/// OpenAI クライアント
|
||||
#[derive(Clone)]
|
||||
pub struct OpenAIClient {
|
||||
/// HTTPクライアント
|
||||
http_client: reqwest::Client,
|
||||
/// APIキー
|
||||
api_key: String,
|
||||
/// モデル名
|
||||
model: String,
|
||||
/// スキーマ
|
||||
scheme: OpenAIScheme,
|
||||
/// ベースURL
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl OpenAIClient {
|
||||
/// 新しいOpenAIクライアントを作成
|
||||
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http_client: reqwest::Client::new(),
|
||||
api_key: api_key.into(),
|
||||
model: model.into(),
|
||||
scheme: OpenAIScheme::default(),
|
||||
base_url: "https://api.openai.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// カスタムHTTPクライアントを設定
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// スキーマを設定
|
||||
pub fn with_scheme(mut self, scheme: OpenAIScheme) -> Self {
|
||||
self.scheme = scheme;
|
||||
self
|
||||
}
|
||||
|
||||
/// ベースURLを設定
|
||||
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.base_url = url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// リクエストヘッダーを構築
|
||||
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
let api_key_val = if self.api_key.is_empty() {
|
||||
// For providers like Ollama, API key might be empty/dummy.
|
||||
// But typical OpenAI requires it.
|
||||
// We'll allow empty if user intends it, but usually it's checked.
|
||||
HeaderValue::from_static("")
|
||||
} else {
|
||||
let mut val = HeaderValue::from_str(&format!("Bearer {}", self.api_key))
|
||||
.map_err(|e| ClientError::Config(format!("Invalid API key: {}", e)))?;
|
||||
val.set_sensitive(true);
|
||||
val
|
||||
};
|
||||
|
||||
if !api_key_val.is_empty() {
|
||||
headers.insert("Authorization", api_key_val);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for OpenAIClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
// Construct the URL: base_url usually ends without slash, path starts with slash or vice versa.
|
||||
// Standard OpenAI base is "https://api.openai.com". Endpoint is "/v1/chat/completions".
|
||||
// If external base_url includes /v1, we should be careful.
|
||||
// Let's assume defaults. If user provides "http://localhost:11434/v1", we append "/chat/completions".
|
||||
// Or cleaner: user provides full base up to version?
|
||||
// Anthropic client uses "{}/v1/messages".
|
||||
// Let's stick to appending "/v1/chat/completions" if base is just host,
|
||||
// OR assume base includes /v1 if user overrides it?
|
||||
// Let's use robust joining or simple assumption matching Anthropic pattern:
|
||||
// Default: https://api.openai.com -> https://api.openai.com/v1/chat/completions
|
||||
|
||||
// However, Ollama default is http://localhost:11434/v1/chat/completions if using OpenAI compact.
|
||||
// If we configure base_url via `with_base_url`, it's flexible.
|
||||
// Let's try to detect if /v1 is present or just append consistently.
|
||||
// Ideally `base_url` should be the root passed to `new`.
|
||||
|
||||
let url = if self.base_url.ends_with("/v1") {
|
||||
format!("{}/chat/completions", self.base_url)
|
||||
} else if self.base_url.ends_with("/") {
|
||||
format!("{}v1/chat/completions", self.base_url)
|
||||
} else {
|
||||
format!("{}/v1/chat/completions", self.base_url)
|
||||
};
|
||||
|
||||
let headers = self.build_headers()?;
|
||||
let body = self.scheme.build_request(&self.model, &request);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// エラーレスポンスをチェック
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
|
||||
// JSONでエラーをパースしてみる
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
// OpenAI error format: { "error": { "message": "...", "type": "...", ... } }
|
||||
let error = json.get("error").unwrap_or(&json);
|
||||
let code = 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();
|
||||
return Err(ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return Err(ClientError::Api {
|
||||
status: Some(status),
|
||||
code: None,
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
|
||||
// SSEストリームを構築
|
||||
let scheme = self.scheme.clone();
|
||||
let byte_stream = response
|
||||
.bytes_stream()
|
||||
.map_err(|e| std::io::Error::other(e));
|
||||
let event_stream = byte_stream.eventsource();
|
||||
|
||||
let stream = event_stream
|
||||
.map(move |result| {
|
||||
match result {
|
||||
Ok(event) => {
|
||||
// SSEイベントをパース
|
||||
// OpenAI stream events are "data: {...}"
|
||||
// event.event is usually "message" (default) or empty.
|
||||
// parse_event takes data string.
|
||||
|
||||
if event.data == "[DONE]" {
|
||||
// End of stream handled inside parse_event usually returning None
|
||||
Ok(None)
|
||||
} else {
|
||||
match scheme.parse_event(&event.data) {
|
||||
Ok(Some(events)) => Ok(Some(events)),
|
||||
Ok(None) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ClientError::Sse(e.to_string())),
|
||||
}
|
||||
})
|
||||
// flatten Option<Vec<Event>> stream to Stream<Event>
|
||||
// map returns Result<Option<Vec<Event>>, Error>
|
||||
// We want Stream<Item = Result<Event, Error>>
|
||||
.map(|res| {
|
||||
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
|
||||
Ok(Some(events)) => Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
|
||||
Ok(None) => Box::pin(futures::stream::empty()),
|
||||
Err(e) => Box::pin(futures::stream::once(async move { Err(e) })),
|
||||
};
|
||||
s
|
||||
})
|
||||
.flatten();
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// OpenAI does not support top_k
|
||||
if config.top_k.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported("top_k", "OpenAI"));
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `model_id → ModelCapability` 静的テーブル。
|
||||
//!
|
||||
//! 既知モデルのみ網羅する。未知モデルは `None` を返し、呼び出し側
|
||||
//! (`HttpTransport` 構築時)に scheme 既定へフォールバックさせる。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Anthropic 公式モデルの既定 capability。
|
||||
///
|
||||
/// `claude-sonnet-*` / `claude-opus-*` / `claude-haiku-*` に対応する。
|
||||
/// `cache_control` は公式のみ有効で、最大 4 breakpoint(公式仕様)。
|
||||
pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
|
||||
if !model_id.starts_with("claude-") {
|
||||
return None;
|
||||
}
|
||||
Some(ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: Some(ReasoningSupport::BudgetTokens),
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Explicit { max_breakpoints: 4 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::AnthropicState;
|
||||
|
||||
/// Anthropicスキーマ
|
||||
///
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde::Serialize;
|
||||
|
||||
use crate::llm_client::{
|
||||
Request,
|
||||
capability::{CacheStrategy, ModelCapability, ReasoningSupport},
|
||||
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
|
||||
};
|
||||
|
||||
@@ -32,6 +33,15 @@ pub(crate) struct AnthropicRequest {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop_sequences: Vec<String>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<AnthropicThinking>,
|
||||
}
|
||||
|
||||
/// Anthropic extended thinking 指示。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum AnthropicThinking {
|
||||
Enabled { budget_tokens: u32 },
|
||||
}
|
||||
|
||||
/// Anthropic message
|
||||
@@ -130,12 +140,40 @@ pub(crate) struct AnthropicTool {
|
||||
}
|
||||
|
||||
impl AnthropicScheme {
|
||||
/// Build Anthropic request from Request
|
||||
pub(crate) fn build_request(&self, model: &str, request: &Request) -> AnthropicRequest {
|
||||
let breakpoints = compute_breakpoints(&request.items, request.cache_anchor);
|
||||
/// Build Anthropic request from Request.
|
||||
///
|
||||
/// `capability.prompt_caching` が [`CacheStrategy::Auto`] のときは
|
||||
/// `cache_control` マーカーを一切挿入しない(Ollama の `/v1/messages`
|
||||
/// 流用時など、サーバ側が `cache_control` を受け付けないケース)。
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> AnthropicRequest {
|
||||
let breakpoints = if matches!(capability.prompt_caching, CacheStrategy::Explicit { .. }) {
|
||||
compute_breakpoints(&request.items, request.cache_anchor)
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
let messages = self.convert_items_to_messages(&request.items, &breakpoints);
|
||||
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
|
||||
|
||||
// Reasoning の投影: capability が BudgetTokens / Both をサポート
|
||||
// していて、request 側で budget_tokens が指定されているときだけ
|
||||
// thinking フィールドを付ける。
|
||||
let supports_budget_tokens = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
|
||||
);
|
||||
let thinking = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.and_then(|rc| rc.budget_tokens)
|
||||
.filter(|_| supports_budget_tokens)
|
||||
.map(|budget_tokens| AnthropicThinking::Enabled { budget_tokens });
|
||||
|
||||
AnthropicRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: request.config.max_tokens.unwrap_or(4096),
|
||||
@@ -147,6 +185,7 @@ impl AnthropicScheme {
|
||||
top_k: request.config.top_k,
|
||||
stop_sequences: request.config.stop_sequences.clone(),
|
||||
stream: true,
|
||||
thinking,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +399,28 @@ fn compute_breakpoints(items: &[Item], cache_anchor: Option<usize>) -> BTreeSet<
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// cache_control が有効になる既定の capability。
|
||||
fn cap_explicit() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Explicit { max_breakpoints: 4 },
|
||||
}
|
||||
}
|
||||
|
||||
/// cache_control を送らない capability(Ollama 等)。
|
||||
fn cap_auto() -> ModelCapability {
|
||||
ModelCapability {
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
..cap_explicit()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_simple_request() {
|
||||
@@ -368,7 +429,7 @@ mod tests {
|
||||
.system("You are a helpful assistant.")
|
||||
.user("Hello!");
|
||||
|
||||
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
|
||||
assert_eq!(anthropic_req.model, "claude-sonnet-4-20250514");
|
||||
assert_eq!(
|
||||
@@ -394,7 +455,7 @@ mod tests {
|
||||
})),
|
||||
);
|
||||
|
||||
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
|
||||
assert_eq!(anthropic_req.tools.len(), 1);
|
||||
assert_eq!(anthropic_req.tools[0].name, "get_weather");
|
||||
@@ -412,7 +473,7 @@ mod tests {
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
|
||||
assert_eq!(anthropic_req.messages.len(), 3);
|
||||
assert_eq!(anthropic_req.messages[0].role, "user");
|
||||
@@ -469,7 +530,7 @@ mod tests {
|
||||
let mut request = Request::new().items(items);
|
||||
request.cache_anchor = Some(0);
|
||||
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
let bps = breakpoint_positions(&req);
|
||||
assert_eq!(bps.len(), 3, "expected 3 breakpoints, got {:?}", bps);
|
||||
for (_, _, cc) in bps {
|
||||
@@ -485,7 +546,7 @@ mod tests {
|
||||
// cache_anchor=None, turn_end=4, head=5.
|
||||
let request = Request::new().items(items);
|
||||
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
let bps = breakpoint_positions(&req);
|
||||
assert_eq!(bps.len(), 2, "expected 2 breakpoints, got {:?}", bps);
|
||||
}
|
||||
@@ -495,7 +556,7 @@ mod tests {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let request = Request::new().user("first ever turn");
|
||||
// latest user at 0 → no turn_end; head=0; no anchor. Collapse → 1.
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
let bps = breakpoint_positions(&req);
|
||||
assert_eq!(bps.len(), 1, "expected 1 breakpoint, got {:?}", bps);
|
||||
}
|
||||
@@ -511,7 +572,7 @@ mod tests {
|
||||
]);
|
||||
request.cache_anchor = Some(0);
|
||||
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
let bps = breakpoint_positions(&req);
|
||||
assert_eq!(bps.len(), 2, "expected collapse to 2, got {:?}", bps);
|
||||
}
|
||||
@@ -525,7 +586,7 @@ mod tests {
|
||||
.user("run it")
|
||||
.item(Item::tool_call("c1", "t", "{}"))
|
||||
.item(Item::tool_result("c1", "result"));
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
let bps = breakpoint_positions(&req);
|
||||
assert_eq!(bps.len(), 1);
|
||||
let (mi, pi, _) = bps[0];
|
||||
@@ -549,7 +610,7 @@ mod tests {
|
||||
let request = Request::new()
|
||||
.user("hello")
|
||||
.assistant("hi there");
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
assert!(
|
||||
matches!(req.messages[0].content, AnthropicContent::Text(_)),
|
||||
"non-breakpoint single-text message should use text shorthand",
|
||||
@@ -563,7 +624,7 @@ mod tests {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut request = Request::new().user("hello");
|
||||
request.cache_anchor = Some(0);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
match &req.messages[0].content {
|
||||
AnthropicContent::Parts(parts) => {
|
||||
assert_eq!(parts.len(), 1);
|
||||
@@ -583,7 +644,7 @@ mod tests {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut request = Request::new().user("hello");
|
||||
request.cache_anchor = Some(0);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
let part = &json["messages"][0]["content"][0];
|
||||
assert_eq!(part["type"], "text");
|
||||
@@ -598,7 +659,7 @@ mod tests {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut request = Request::new().user("one");
|
||||
request.cache_anchor = Some(99);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
// Only the Head breakpoint survives.
|
||||
let bps = breakpoint_positions(&req);
|
||||
assert_eq!(bps.len(), 1);
|
||||
@@ -607,11 +668,22 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_items_produce_no_breakpoints() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &Request::new());
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &Request::new(), &cap_explicit());
|
||||
assert!(req.messages.is_empty());
|
||||
assert!(breakpoint_positions(&req).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_auto_does_not_add_cache_control() {
|
||||
// Ollama のように `CacheStrategy::Auto` のときは cache_control
|
||||
// マーカーを一切付けない。breakpoint 計算も走らないこと。
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut request = Request::new().user("hello");
|
||||
request.cache_anchor = Some(0);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_auto());
|
||||
assert!(breakpoint_positions(&req).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_definitions_carry_no_cache_control() {
|
||||
// Tool JSON schema must serialise unchanged — no sneak-in of
|
||||
@@ -623,7 +695,7 @@ mod tests {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})));
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request);
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
let tool = &json["tools"][0];
|
||||
assert!(tool.get("cache_control").is_none());
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! `impl Scheme for AnthropicScheme`
|
||||
//!
|
||||
//! Anthropic Messages API の wire 表現に必要な URL・ヘッダ・SSE パース・
|
||||
//! リクエスト body 生成を共通 `Scheme` trait にぶら下げる。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
capability::ModelCapability,
|
||||
event::{BlockStop, BlockType, Event},
|
||||
auth::AuthRequirement,
|
||||
scheme::Scheme,
|
||||
types::Request,
|
||||
};
|
||||
|
||||
use super::AnthropicScheme;
|
||||
|
||||
/// Anthropic の SSE パースで必要な状態。
|
||||
///
|
||||
/// `content_block_stop` イベントは `block_type` を持たない仕様なので、
|
||||
/// 直前の `content_block_start` で観測した `block_type` を保持して
|
||||
/// `BlockStop` に書き戻す。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AnthropicState {
|
||||
current_block_type: Option<BlockType>,
|
||||
}
|
||||
|
||||
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> {
|
||||
let Some(mut event) = self.parse_event(event_type, data)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
match &event {
|
||||
Event::BlockStart(start) => {
|
||||
state.current_block_type = Some(start.block_type);
|
||||
}
|
||||
Event::BlockStop(stop) => {
|
||||
if let Some(block_type) = state.current_block_type.take() {
|
||||
event = Event::BlockStop(BlockStop {
|
||||
block_type,
|
||||
..stop.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(vec![event])
|
||||
}
|
||||
|
||||
fn capability_for(&self, model_id: &str) -> Option<ModelCapability> {
|
||||
super::capability::lookup(model_id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `model_id → ModelCapability` 静的テーブル(Google Gemini)。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
|
||||
if !model_id.starts_with("gemini-") {
|
||||
return None;
|
||||
}
|
||||
// 2.5 系以降は thinking / reasoning を持つ
|
||||
let reasoning = if model_id.starts_with("gemini-2.5")
|
||||
|| model_id.starts_with("gemini-3")
|
||||
{
|
||||
Some(ReasoningSupport::BudgetTokens)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
})
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// Geminiスキーマ
|
||||
///
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningSupport},
|
||||
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
||||
};
|
||||
|
||||
@@ -139,11 +140,26 @@ pub(crate) struct GeminiGenerationConfig {
|
||||
/// 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) -> GeminiRequest {
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> GeminiRequest {
|
||||
let contents = self.convert_items_to_contents(&request.items);
|
||||
|
||||
// System prompt
|
||||
@@ -177,6 +193,22 @@ impl GeminiScheme {
|
||||
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()
|
||||
.and_then(|rc| rc.budget_tokens)
|
||||
.filter(|_| supports_budget)
|
||||
.map(|budget| GeminiThinkingConfig {
|
||||
thinking_budget: budget as i32,
|
||||
});
|
||||
|
||||
// Generation config
|
||||
let generation_config = Some(GeminiGenerationConfig {
|
||||
max_output_tokens: request.config.max_tokens,
|
||||
@@ -184,6 +216,7 @@ impl GeminiScheme {
|
||||
top_p: request.config.top_p,
|
||||
top_k: request.config.top_k,
|
||||
stop_sequences: request.config.stop_sequences.clone(),
|
||||
thinking_config,
|
||||
});
|
||||
|
||||
GeminiRequest {
|
||||
@@ -341,6 +374,17 @@ impl GeminiScheme {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{CacheStrategy, StructuredOutput, ToolCallingSupport};
|
||||
|
||||
fn cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_simple_request() {
|
||||
@@ -349,7 +393,7 @@ mod tests {
|
||||
.system("You are a helpful assistant.")
|
||||
.user("Hello!");
|
||||
|
||||
let gemini_req = scheme.build_request(&request);
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert!(gemini_req.system_instruction.is_some());
|
||||
assert_eq!(gemini_req.contents.len(), 1);
|
||||
@@ -371,7 +415,7 @@ mod tests {
|
||||
})),
|
||||
);
|
||||
|
||||
let gemini_req = scheme.build_request(&request);
|
||||
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);
|
||||
@@ -387,7 +431,7 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new().user("Hello").assistant("Hi there!");
|
||||
|
||||
let gemini_req = scheme.build_request(&request);
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.contents.len(), 2);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
@@ -406,7 +450,7 @@ mod tests {
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let gemini_req = scheme.build_request(&request);
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.contents.len(), 3);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `impl Scheme for GeminiScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
capability::ModelCapability,
|
||||
event::Event,
|
||||
auth::AuthRequirement,
|
||||
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 capability_for(&self, model_id: &str) -> Option<ModelCapability> {
|
||||
super::capability::lookup(model_id)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,80 @@
|
||||
//! 各APIスキーマごとの変換ロジック
|
||||
//! - リクエスト変換: Request → プロバイダ固有JSON
|
||||
//! - レスポンス変換: SSEイベント → Event
|
||||
//!
|
||||
//! [`Scheme`] trait により `HttpTransport<S>` から scheme 固有の差分
|
||||
//! (パス、ヘッダ、認証要件、body 生成、SSE パース)をすべて委譲する。
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod gemini;
|
||||
pub mod openai;
|
||||
pub mod openai_chat;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::auth::AuthRequirement;
|
||||
use super::capability::ModelCapability;
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::types::Request;
|
||||
|
||||
/// 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 が要求する認証形式。`build_client` 時に
|
||||
/// [`AuthRef`](../../../manifest/enum.AuthRef.html) と照合する。
|
||||
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>;
|
||||
|
||||
/// 既知モデル ID の能力テーブル引き。未知なら `None` を返す
|
||||
/// ので、呼び出し側は scheme ごとの安全側デフォルト
|
||||
/// ([`ModelCapability::minimal`])にフォールバックする。
|
||||
fn capability_for(&self, model_id: &str) -> Option<ModelCapability>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! `model_id → ModelCapability` 静的テーブル(OpenAI Chat Completions)。
|
||||
//!
|
||||
//! OpenAI 本家の主要モデルのみ網羅する。OpenRouter / xAI / Groq 等は
|
||||
//! モデル ID が各社独自なので、マニフェスト側で明示 override する
|
||||
//! 前提。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
|
||||
// GPT-5 / o1 / o3 / o4 reasoning 系
|
||||
if model_id.starts_with("gpt-5")
|
||||
|| model_id.starts_with("o1")
|
||||
|| model_id.starts_with("o3")
|
||||
|| model_id.starts_with("o4")
|
||||
{
|
||||
return Some(ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: Some(ReasoningSupport::Effort),
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
});
|
||||
}
|
||||
// GPT-4o / GPT-4 系
|
||||
if model_id.starts_with("gpt-4") {
|
||||
return Some(ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
});
|
||||
}
|
||||
// GPT-3.5 系(旧式・structured output 限定)
|
||||
if model_id.starts_with("gpt-3.5") {
|
||||
return Some(ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonObject,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
+2
@@ -3,8 +3,10 @@
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// OpenAIスキーマ
|
||||
///
|
||||
+45
-6
@@ -7,6 +7,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningEffort, ReasoningSupport},
|
||||
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
||||
};
|
||||
|
||||
@@ -34,6 +35,9 @@ pub(crate) struct OpenAIRequest {
|
||||
pub tools: Vec<OpenAITool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<String>,
|
||||
/// Reasoning effort(o1 / o3 / o4 / gpt-5 系で有効)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -110,7 +114,12 @@ pub(crate) struct OpenAIToolCallFunction {
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// Build OpenAI request from Request
|
||||
pub(crate) fn build_request(&self, model: &str, request: &Request) -> OpenAIRequest {
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> OpenAIRequest {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// Add system message if present
|
||||
@@ -135,6 +144,24 @@ impl OpenAIScheme {
|
||||
(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()
|
||||
.and_then(|rc| rc.effort)
|
||||
.filter(|_| supports_effort)
|
||||
.map(|effort| match effort {
|
||||
ReasoningEffort::Low => "low",
|
||||
ReasoningEffort::Medium => "medium",
|
||||
ReasoningEffort::High => "high",
|
||||
});
|
||||
|
||||
OpenAIRequest {
|
||||
model: model.to_string(),
|
||||
max_completion_tokens,
|
||||
@@ -149,6 +176,7 @@ impl OpenAIScheme {
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: None,
|
||||
reasoning_effort,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,13 +322,24 @@ impl OpenAIScheme {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{CacheStrategy, StructuredOutput, ToolCallingSupport};
|
||||
|
||||
fn cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.model, "gpt-4o");
|
||||
assert_eq!(body.messages.len(), 2);
|
||||
@@ -321,7 +360,7 @@ mod tests {
|
||||
.user("Check weather")
|
||||
.tool(ToolDefinition::new("weather").description("Get weather"));
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request);
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
assert_eq!(body.tools.len(), 1);
|
||||
assert_eq!(body.tools[0].function.name, "weather");
|
||||
}
|
||||
@@ -331,7 +370,7 @@ mod tests {
|
||||
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);
|
||||
let body = scheme.build_request("llama3", &request, &cap());
|
||||
|
||||
assert_eq!(body.max_tokens, Some(100));
|
||||
assert!(body.max_completion_tokens.is_none());
|
||||
@@ -342,7 +381,7 @@ mod tests {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new().user("Hello").max_tokens(100);
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request);
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.max_completion_tokens, Some(100));
|
||||
assert!(body.max_tokens.is_none());
|
||||
@@ -360,7 +399,7 @@ mod tests {
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request);
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.messages.len(), 3);
|
||||
assert_eq!(body.messages[0].role, "user");
|
||||
@@ -0,0 +1,57 @@
|
||||
//! `impl Scheme for OpenAIScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
capability::ModelCapability,
|
||||
event::Event,
|
||||
auth::AuthRequirement,
|
||||
scheme::Scheme,
|
||||
types::Request,
|
||||
};
|
||||
|
||||
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 capability_for(&self, model_id: &str) -> Option<ModelCapability> {
|
||||
super::capability::lookup(model_id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
|
||||
//! HTTP クライアントで扱う。
|
||||
//!
|
||||
//! 旧 `providers/{anthropic,openai,gemini,ollama}.rs` を置き換える。
|
||||
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲する。
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::LlmClient;
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::auth::AuthRequirement;
|
||||
use super::scheme::Scheme;
|
||||
use super::types::Request;
|
||||
|
||||
/// `AuthRef` を解決したランタイム表現。`crates/provider` が構築する。
|
||||
///
|
||||
/// `AuthRef::ApiKey` → 読み取った文字列、`AuthRef::None` → `None`。
|
||||
/// `CodexOAuth` 等、動的に更新される認証は別途 `Custom` バリアントを
|
||||
/// 追加する余地を残す(本チケットでは未実装)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResolvedAuth {
|
||||
None,
|
||||
ApiKey(String),
|
||||
}
|
||||
|
||||
impl ResolvedAuth {
|
||||
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
|
||||
///
|
||||
/// `ResolvedAuth::None` は認証を付けないという宣言なので、どの
|
||||
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
|
||||
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)。
|
||||
pub fn matches(&self, req: AuthRequirement) -> bool {
|
||||
match (self, req) {
|
||||
(Self::None, _) => true,
|
||||
(
|
||||
Self::ApiKey(_),
|
||||
AuthRequirement::Bearer | AuthRequirement::XApiKey | AuthRequirement::QueryParam { .. },
|
||||
) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// scheme 共通の HTTP 通信層。
|
||||
pub struct HttpTransport<S: Scheme> {
|
||||
http_client: reqwest::Client,
|
||||
scheme: S,
|
||||
model_id: String,
|
||||
base_url: String,
|
||||
auth: ResolvedAuth,
|
||||
capability: ModelCapability,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// カスタム 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
|
||||
}
|
||||
}
|
||||
|
||||
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
match (self.scheme.required_auth(), &self.auth) {
|
||||
(AuthRequirement::None, _) | (_, ResolvedAuth::None) => {}
|
||||
(AuthRequirement::Bearer, ResolvedAuth::ApiKey(key)) => {
|
||||
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);
|
||||
}
|
||||
(AuthRequirement::XApiKey, ResolvedAuth::ApiKey(key)) => {
|
||||
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` で付与済み
|
||||
}
|
||||
(AuthRequirement::Custom, _) => {
|
||||
// 今チケットでは Custom は使わない。Codex OAuth で追加予定
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
let url = self.build_url();
|
||||
let headers = self.build_headers()?;
|
||||
let body = self
|
||||
.scheme
|
||||
.build_request_body(&self.model_id, &request, &self.capability);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let text = response.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("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();
|
||||
return Err(ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
return Err(ClientError::Api {
|
||||
status: Some(status),
|
||||
code: None,
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
|
||||
let scheme = self.scheme.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) => Err(ClientError::Sse(e.to_string())),
|
||||
})
|
||||
.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))
|
||||
}
|
||||
}
|
||||
@@ -565,6 +565,12 @@ pub struct RequestConfig {
|
||||
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 {
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
use llm_worker::Worker;
|
||||
use llm_worker::llm_client::providers::ollama::OllamaClient;
|
||||
use llm_worker::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
use llm_worker::llm_client::scheme::anthropic::AnthropicScheme;
|
||||
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let client = OllamaClient::new("dummy-model");
|
||||
let cap = ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
};
|
||||
let client = HttpTransport::new(
|
||||
AnthropicScheme::new(),
|
||||
"dummy-model".to_string(),
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
cap,
|
||||
);
|
||||
let worker = Worker::new(client);
|
||||
let mut locked = worker.lock();
|
||||
let def: llm_worker::tool::ToolDefinition = Arc::new(|| panic!("unused"));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
error[E0599]: no method named `register_tool` found for struct `Worker<OllamaClient, Locked>` in the current scope
|
||||
--> tests/ui/locked_register_tool.rs:10:20
|
||||
error[E0599]: no method named `register_tool` found for struct `Worker<HttpTransport<AnthropicScheme>, Locked>` in the current scope
|
||||
--> tests/ui/locked_register_tool.rs:27:20
|
||||
|
|
||||
10 | let _ = locked.register_tool(def);
|
||||
| ^^^^^^^^^^^^^ method not found in `Worker<OllamaClient, Locked>`
|
||||
27 | let _ = locked.register_tool(def);
|
||||
| ^^^^^^^^^^^^^ method not found in `Worker<HttpTransport<AnthropicScheme>, Locked>`
|
||||
|
|
||||
= note: the method was found for
|
||||
- `Worker<C>`
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
use llm_worker::Worker;
|
||||
use llm_worker::llm_client::providers::ollama::OllamaClient;
|
||||
use llm_worker::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
use llm_worker::llm_client::scheme::anthropic::AnthropicScheme;
|
||||
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let client = OllamaClient::new("dummy-model");
|
||||
let cap = ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
};
|
||||
let client = HttpTransport::new(
|
||||
AnthropicScheme::new(),
|
||||
"dummy-model".to_string(),
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
cap,
|
||||
);
|
||||
let worker = Worker::new(client);
|
||||
let handle = worker.tool_server_handle();
|
||||
let def: llm_worker::tool::ToolDefinition = Arc::new(|| panic!("unused"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
error[E0624]: method `register_tool` is private
|
||||
--> tests/ui/tool_server_handle_register_tool.rs:10:20
|
||||
--> tests/ui/tool_server_handle_register_tool.rs:27:20
|
||||
|
|
||||
10 | let _ = handle.register_tool(def);
|
||||
27 | let _ = handle.register_tool(def);
|
||||
| ^^^^^^^^^^^^^ private method
|
||||
|
|
||||
::: src/tool_server.rs
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
use llm_worker::llm_client::providers::openai::OpenAIClient;
|
||||
use llm_worker::{Worker, WorkerError};
|
||||
|
||||
#[test]
|
||||
fn test_openai_top_k_warning() {
|
||||
// Create client with dummy key (validate_config doesn't make network calls, so safe)
|
||||
let client = OpenAIClient::new("dummy-key", "gpt-4o");
|
||||
|
||||
// Create Worker with top_k set (OpenAI doesn't support top_k)
|
||||
let worker = Worker::new(client).top_k(50);
|
||||
|
||||
// Run validate()
|
||||
let result = worker.validate();
|
||||
|
||||
// Verify error is returned and ConfigWarnings is included
|
||||
match result {
|
||||
Err(WorkerError::ConfigWarnings(warnings)) => {
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(warnings[0].option_name, "top_k");
|
||||
println!("Got expected warning: {}", warnings[0]);
|
||||
}
|
||||
Ok(_) => panic!("Should have returned validation error"),
|
||||
Err(e) => panic!("Unexpected error type: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_valid_config() {
|
||||
let client = OpenAIClient::new("dummy-key", "gpt-4o");
|
||||
|
||||
// Valid configuration (temperature only)
|
||||
let worker = Worker::new(client).temperature(0.7);
|
||||
|
||||
// Run validate()
|
||||
let result = worker.validate();
|
||||
|
||||
// Verify success
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
Reference in New Issue
Block a user