refactor: fold provider crate into engine

This commit is contained in:
Keisuke Hirata 2026-07-15 00:58:29 +09:00
parent 1505c5e8a3
commit 839fc7b40c
No known key found for this signature in database
33 changed files with 263 additions and 279 deletions

103
Cargo.lock generated
View File

@ -548,16 +548,6 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@ -1668,11 +1658,9 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@ -2108,6 +2096,8 @@ name = "llm-engine"
version = "0.2.1" version = "0.2.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64",
"chrono",
"clap", "clap",
"dotenv", "dotenv",
"eventsource-stream", "eventsource-stream",
@ -2231,6 +2221,7 @@ dependencies = [
"serde", "serde",
"serde_ignored", "serde_ignored",
"serde_json", "serde_json",
"serial_test",
"sha2 0.10.9", "sha2 0.10.9",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
@ -2907,28 +2898,6 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "provider"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"chrono",
"llm-engine",
"manifest",
"reqwest",
"secrets",
"serde",
"serde_json",
"serial_test",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml",
"tracing",
"wiremock",
]
[[package]] [[package]]
name = "pulldown-cmark" name = "pulldown-cmark"
version = "0.13.3" version = "0.13.3"
@ -3291,7 +3260,6 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"encoding_rs",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
@ -3305,7 +3273,6 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
@ -3447,7 +3414,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [ dependencies = [
"core-foundation 0.10.1", "core-foundation",
"core-foundation-sys", "core-foundation-sys",
"jni", "jni",
"log", "log",
@ -3501,15 +3468,6 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
@ -3550,12 +3508,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]] [[package]]
name = "secrets" name = "secrets"
version = "0.1.0" version = "0.1.0"
@ -3574,7 +3526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"core-foundation 0.10.1", "core-foundation",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
"security-framework-sys", "security-framework-sys",
@ -3711,24 +3663,23 @@ dependencies = [
[[package]] [[package]]
name = "serial_test" name = "serial_test"
version = "3.4.0" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
dependencies = [ dependencies = [
"futures-executor", "futures-executor",
"futures-util", "futures-util",
"log", "log",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"scc",
"serial_test_derive", "serial_test_derive",
] ]
[[package]] [[package]]
name = "serial_test_derive" name = "serial_test_derive"
version = "3.4.0" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -4016,27 +3967,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.46" version = "0.4.46"
@ -4602,7 +4532,6 @@ dependencies = [
"manifest", "manifest",
"minijinja", "minijinja",
"protocol", "protocol",
"provider",
"pulldown-cmark", "pulldown-cmark",
"ratatui", "ratatui",
"secrets", "secrets",
@ -5460,17 +5389,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@ -5804,11 +5722,12 @@ dependencies = [
"memory", "memory",
"minijinja", "minijinja",
"protocol", "protocol",
"provider",
"reqwest", "reqwest",
"schemars", "schemars",
"secrets",
"serde", "serde",
"serde_json", "serde_json",
"serial_test",
"session-metrics", "session-metrics",
"session-store", "session-store",
"tempfile", "tempfile",

View File

@ -13,7 +13,6 @@ members = [
"crates/plugin-pdk", "crates/plugin-pdk",
"crates/yoi", "crates/yoi",
"crates/protocol", "crates/protocol",
"crates/provider",
"crates/session-metrics", "crates/session-metrics",
"crates/session-analytics", "crates/session-analytics",
"crates/lint-common", "crates/lint-common",
@ -39,7 +38,6 @@ default-members = [
"crates/plugin-pdk", "crates/plugin-pdk",
"crates/yoi", "crates/yoi",
"crates/protocol", "crates/protocol",
"crates/provider",
"crates/session-metrics", "crates/session-metrics",
"crates/session-analytics", "crates/session-analytics",
"crates/lint-common", "crates/lint-common",
@ -72,7 +70,6 @@ worker-runtime = { path = "crates/worker-runtime" }
yoi-plugin-pdk = { path = "crates/plugin-pdk" } yoi-plugin-pdk = { path = "crates/plugin-pdk" }
yoi = { path = "crates/yoi" } yoi = { path = "crates/yoi" }
protocol = { path = "crates/protocol" } protocol = { path = "crates/protocol" }
provider = { path = "crates/provider" }
session-metrics = { path = "crates/session-metrics" } session-metrics = { path = "crates/session-metrics" }
session-analytics = { path = "crates/session-analytics" } session-analytics = { path = "crates/session-analytics" }
session-store = { path = "crates/session-store" } session-store = { path = "crates/session-store" }

View File

@ -5,6 +5,10 @@ version = "0.2.1"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
[features]
default = []
codex = ["dep:base64", "dep:chrono"]
[dependencies] [dependencies]
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
@ -12,11 +16,13 @@ thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "sync", "time"] }
tokio-util = "0.7" tokio-util = "0.7"
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "native-tls", "http2"] } reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "native-tls", "http2"] }
eventsource-stream = "0.2" eventsource-stream = "0.2"
zstd = "0.13" zstd = "0.13"
base64 = { version = "0.22.1", optional = true }
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
llm-engine-macros = { workspace = true } llm-engine-macros = { workspace = true }
[dev-dependencies] [dev-dependencies]

View File

@ -45,6 +45,7 @@ pub(crate) mod callback;
pub mod event; pub mod event;
pub mod interceptor; pub mod interceptor;
pub mod llm_client; pub mod llm_client;
pub mod providers;
pub mod prune; pub mod prune;
pub mod state; pub mod state;
pub mod timeline; pub mod timeline;

View File

@ -40,12 +40,4 @@ pub enum AuthRequirement {
pub trait AuthProvider: Send + Sync + std::fmt::Debug { pub trait AuthProvider: Send + Sync + std::fmt::Debug {
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。 /// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>; async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
/// Conversation header / request compression が必要な backend profile かどうか。
///
/// transport は呼び出し側の具象型を知らないため、この hook だけで
/// 追加の wire behavior を切り替える。
fn is_codex_backend(&self) -> bool {
false
}
} }

View File

@ -1,15 +1,15 @@
//! LLM response stream を開く前の transient error 向けリトライポリシー。 //! LLM response stream を開く前の transient error 向けリトライポリシー。
//! //!
//! Engine が `LlmClient::stream` の open error に対して `is_retryable` を見て //! `LlmClient::stream` の open error に対して `is_retryable` を見て
//! retry / backoff / TUI event / cancellation をまとめて管理する。 //! retry / backoff / cancellation をまとめて管理する。
//! SSE 読み出し開始後の失敗は対象外。 //! SSE 読み出し開始後の失敗は対象外。
use std::time::Duration; use std::time::Duration;
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。 /// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
/// ///
/// `Default` は llm-engine 全体の固定値を返す。manifest 経由の上書きが /// `Default` は llm-engine 全体の固定値を返す。呼び出し側からの上書きが
/// 必要になったら拡張する(現状は不要 → `tickets/llm-engine-transient-retry.md` /// 必要になったら拡張する
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RetryPolicy { pub struct RetryPolicy {
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から /// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から

View File

@ -1,7 +1,7 @@
//! Anthropic scheme の wire-level 既定 capability。 //! Anthropic scheme の wire-level 既定 capability。
//! //!
//! モデル ID 固有のテーブル(`claude-*` など)は高レベル構築層 //! モデル ID 固有のテーブル(`claude-*` など)は client construction layer
//! (`provider::capability`)の責務。ここでは未知モデルでも「この wire で //! の責務。ここでは未知モデルでも「この wire で
//! 安全に送れる最小共通項」を返すだけに留める。 //! 安全に送れる最小共通項」を返すだけに留める。
use crate::llm_client::capability::{ use crate::llm_client::capability::{

View File

@ -1,7 +1,7 @@
//! Gemini scheme の wire-level 既定 capability。 //! Gemini scheme の wire-level 既定 capability。
//! //!
//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は //! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は
//! 高レベル構築層(`provider::capability`)の責務。ここでは wire の //! client construction layer の責務。ここでは wire の
//! 保守的 default のみ。 //! 保守的 default のみ。
use crate::llm_client::capability::{ use crate::llm_client::capability::{

View File

@ -1,7 +1,7 @@
//! OpenAI Chat Completions scheme の wire-level 既定 capability。 //! OpenAI Chat Completions scheme の wire-level 既定 capability。
//! //!
//! モデル ID 固有のテーブル(`gpt-5` 系など)は高レベル構築層 //! モデル ID 固有のテーブル(`gpt-5` 系など)は client construction layer
//! (`provider::capability`)の責務。ここでは wire の保守的 default のみ。 //! の責務。ここでは wire の保守的 default のみ。
use crate::llm_client::capability::{ use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,

View File

@ -65,7 +65,7 @@ impl Scheme for OpenAIResponsesScheme {
if !self.send_max_output_tokens && config.max_tokens.is_some() { if !self.send_max_output_tokens && config.max_tokens.is_some() {
warnings.push(ConfigWarning::unsupported( warnings.push(ConfigWarning::unsupported(
"max_tokens", "max_tokens",
"OpenAI Responses (ChatGPT backend)", "OpenAI Responses compatible backend",
)); ));
} }
// Same for `temperature` / `top_p` on compatible backends that // Same for `temperature` / `top_p` on compatible backends that
@ -74,13 +74,13 @@ impl Scheme for OpenAIResponsesScheme {
if config.temperature.is_some() { if config.temperature.is_some() {
warnings.push(ConfigWarning::unsupported( warnings.push(ConfigWarning::unsupported(
"temperature", "temperature",
"OpenAI Responses (ChatGPT backend)", "OpenAI Responses compatible backend",
)); ));
} }
if config.top_p.is_some() { if config.top_p.is_some() {
warnings.push(ConfigWarning::unsupported( warnings.push(ConfigWarning::unsupported(
"top_p", "top_p",
"OpenAI Responses (ChatGPT backend)", "OpenAI Responses compatible backend",
)); ));
} }
} }

View File

@ -1,8 +1,8 @@
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の //! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
//! HTTP クライアントで扱う。 //! HTTP クライアントで扱う。
//! //!
//! 旧 `providers/{anthropic,openai,gemini,ollama}.rs` を置き換える。 //! scheme 固有の差分は [`Scheme`] trait 実装に委譲し、backend 固有の
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲する //! HTTP policy は [`TransportPolicy`] で明示的に差し込む
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
@ -63,6 +63,71 @@ impl ResolvedAuth {
} }
} }
/// 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> { fn header_value_for_diagnostics(headers: &HeaderMap, name: &str) -> Option<String> {
headers headers
.get(name) .get(name)
@ -120,6 +185,7 @@ pub struct HttpTransport<S: Scheme> {
base_url: String, base_url: String,
auth: ResolvedAuth, auth: ResolvedAuth,
capability: ModelCapability, capability: ModelCapability,
policy: TransportPolicy,
} }
impl<S: Scheme> HttpTransport<S> { impl<S: Scheme> HttpTransport<S> {
@ -141,9 +207,16 @@ impl<S: Scheme> HttpTransport<S> {
base_url, base_url,
auth, auth,
capability, 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 クライアントを差し込む(テスト等)。 /// カスタム HTTP クライアントを差し込む(テスト等)。
pub fn with_http_client(mut self, client: reqwest::Client) -> Self { pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = client; self.http_client = client;
@ -206,13 +279,6 @@ impl<S: Scheme> HttpTransport<S> {
Ok(headers) Ok(headers)
} }
fn is_codex_backend(&self) -> bool {
match &self.auth {
ResolvedAuth::Custom(provider) => provider.is_codex_backend(),
_ => false,
}
}
fn apply_stream_headers( fn apply_stream_headers(
&self, &self,
headers: &mut HeaderMap, headers: &mut HeaderMap,
@ -220,18 +286,26 @@ impl<S: Scheme> HttpTransport<S> {
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
if self.is_codex_backend() 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 Some(cache_key) = request.cache_key.as_deref()
{ {
let value = HeaderValue::from_str(cache_key).map_err(|e| { let value = HeaderValue::from_str(cache_key).map_err(|e| {
ClientError::Config(format!("invalid Codex conversation header: {e}")) ClientError::Config(format!("invalid conversation header value: {e}"))
})?; })?;
// Send both current hyphenated conversation headers and the
// legacy underscore form for compatibility with existing backends.
headers.insert(HeaderName::from_static("session-id"), value.clone()); headers.insert(HeaderName::from_static("session-id"), value.clone());
headers.insert(HeaderName::from_static("thread-id"), value.clone()); if include_thread_id {
headers.insert(HeaderName::from_static("session_id"), value.clone()); headers.insert(HeaderName::from_static("thread-id"), value.clone());
headers.insert(HeaderName::from_static("x-client-request-id"), value); }
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(()) Ok(())
@ -242,19 +316,22 @@ impl<S: Scheme> HttpTransport<S> {
body: &serde_json::Value, body: &serde_json::Value,
headers: &mut HeaderMap, headers: &mut HeaderMap,
) -> Result<RequestBody, ClientError> { ) -> Result<RequestBody, ClientError> {
if !self.is_codex_backend() { match self.policy.request_body_encoding {
return Ok(RequestBody::Json(body.clone())); 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,
})
}
} }
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,
})
} }
} }
@ -383,6 +460,7 @@ impl<S: Scheme + Clone> Clone for HttpTransport<S> {
base_url: self.base_url.clone(), base_url: self.base_url.clone(),
auth: self.auth.clone(), auth: self.auth.clone(),
capability: self.capability.clone(), capability: self.capability.clone(),
policy: self.policy.clone(),
} }
} }
} }
@ -446,7 +524,8 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
"path": path, "path": path,
"auth_kind": auth_kind(&self.auth), "auth_kind": auth_kind(&self.auth),
"required_auth": format!("{:?}", self.scheme.required_auth()), "required_auth": format!("{:?}", self.scheme.required_auth()),
"codex_backend": self.is_codex_backend(), "request_body_encoding": format!("{:?}", self.policy.request_body_encoding),
"conversation_headers": format!("{:?}", self.policy.conversation_headers),
"cache_key_present": request.cache_key.is_some(), "cache_key_present": request.cache_key.is_some(),
"stream_open_timeout_ms": DEFAULT_STREAM_OPEN_TIMEOUT.as_millis() as u64, "stream_open_timeout_ms": DEFAULT_STREAM_OPEN_TIMEOUT.as_millis() as u64,
}), }),
@ -677,9 +756,7 @@ mod tests {
use serde_json::json; use serde_json::json;
#[derive(Debug)] #[derive(Debug)]
struct TestAuthProvider { struct TestAuthProvider;
codex: bool,
}
#[async_trait] #[async_trait]
impl AuthProvider for TestAuthProvider { impl AuthProvider for TestAuthProvider {
@ -695,10 +772,6 @@ mod tests {
), ),
]) ])
} }
fn is_codex_backend(&self) -> bool {
self.codex
}
} }
#[derive(Clone)] #[derive(Clone)]
@ -827,10 +900,9 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn codex_backend_adds_conversation_headers_and_zstd_body() { async fn transport_policy_adds_conversation_headers_and_zstd_body() {
let transport = transport(ResolvedAuth::Custom(Arc::new(TestAuthProvider { let transport = transport(ResolvedAuth::Custom(Arc::new(TestAuthProvider)))
codex: true, .with_transport_policy(TransportPolicy::openai_compatible_zstd());
})));
let request = Request::new().user("hello").cache_key("segment-123"); let request = Request::new().user("hello").cache_key("segment-123");
let mut headers = transport.build_headers().await.unwrap(); let mut headers = transport.build_headers().await.unwrap();
transport transport
@ -869,7 +941,7 @@ mod tests {
raw_json_bytes, raw_json_bytes,
} = encoded } = encoded
else { else {
panic!("Codex backend request body must be zstd-compressed"); panic!("transport policy should zstd-compress request body");
}; };
assert!(raw_json_bytes > 0); assert!(raw_json_bytes > 0);
let decoded = zstd::stream::decode_all(std::io::Cursor::new(compressed)).unwrap(); let decoded = zstd::stream::decode_all(std::io::Cursor::new(compressed)).unwrap();
@ -878,7 +950,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn non_codex_request_does_not_get_codex_only_headers_or_compression() { async fn standard_policy_does_not_get_conversation_headers_or_compression() {
let transport = transport(ResolvedAuth::ApiKey("api-key".to_string())); let transport = transport(ResolvedAuth::ApiKey("api-key".to_string()));
let request = Request::new().user("hello").cache_key("segment-123"); let request = Request::new().user("hello").cache_key("segment-123");
let mut headers = transport.build_headers().await.unwrap(); let mut headers = transport.build_headers().await.unwrap();
@ -900,7 +972,7 @@ mod tests {
assert!(headers.get(CONTENT_ENCODING).is_none()); assert!(headers.get(CONTENT_ENCODING).is_none());
let RequestBody::Json(decoded) = encoded else { let RequestBody::Json(decoded) = encoded else {
panic!("non-Codex request body must remain normal JSON"); panic!("standard transport policy should keep request body as JSON");
}; };
assert_eq!(decoded["prompt_cache_key"], "segment-123"); assert_eq!(decoded["prompt_cache_key"], "segment-123");
} }

View File

@ -1,7 +1,7 @@
//! `~/.codex/auth.json` の読み書き。 //! `~/.codex/auth.json` の読み書き。
//! //!
//! Codex CLI と schema を共有するが、yoi は知らないフィールドを //! Codex CLI と schema を共有するが、知らないフィールドを失わないよう
//! 失わないようファイル全体を `serde_json::Value` で保持し、必要箇所 //! ファイル全体を `serde_json::Value` で保持し、必要箇所
//! のみアクセスする。書込は `mode 0o600` を再設定Codex CLI 同様)、 //! のみアクセスする。書込は `mode 0o600` を再設定Codex CLI 同様)、
//! ファイルロックは取らないmanager 側で guarded reload //! ファイルロックは取らないmanager 側で guarded reload
@ -106,7 +106,7 @@ pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と /// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と
/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。 /// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。
/// ///
/// 並行する Codex CLI / 別 yoi プロセスが先に refresh していた場合の /// 並行する Codex CLI / 別プロセスが先に refresh していた場合の
/// fields を保護するため、書込前に再 load して merge する。 /// fields を保護するため、書込前に再 load して merge する。
pub async fn persist_refreshed( pub async fn persist_refreshed(
path: &Path, path: &Path,

View File

@ -6,7 +6,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use llm_engine::llm_client::ClientError; use crate::llm_client::ClientError;
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]

View File

@ -4,11 +4,11 @@
//! //!
//! 設計: //! 設計:
//! //!
//! - llm-engine は [`AuthProvider`] trait しか知らず、実体である //! - HTTP transport は [`AuthProvider`] trait だけを見て、実体である
//! [`CodexAuthProvider`] はこのクレートに置くfeedback_llm_engine_scope //! [`CodexAuthProvider`] はこの optional module に置く
//! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh //! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh
//! Codex CLI と同じバッファなし) //! Codex CLI と同じバッファなし)
//! - 並行する Codex CLI / 別 yoi の refresh と取り違えないよう、 //! - 並行する Codex CLI / 別プロセスの refresh と取り違えないよう、
//! refresh 直前に再 load して account_id 一致を確認guarded reload //! refresh 直前に再 load して account_id 一致を確認guarded reload
//! - ファイルロックは取らず、書込前に再 load + diff merge で吸収 //! - ファイルロックは取らず、書込前に再 load + diff merge で吸収
//! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内 //! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内
@ -21,9 +21,9 @@ mod refresh;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use crate::llm_client::{ClientError, auth::AuthProvider};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use llm_engine::llm_client::{ClientError, auth::AuthProvider};
use reqwest::header::{HeaderName, HeaderValue}; use reqwest::header::{HeaderName, HeaderValue};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -152,7 +152,7 @@ impl CodexAuthProvider {
.map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?; .map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?;
out.push((HeaderName::from_static("chatgpt-account-id"), acc_val)); out.push((HeaderName::from_static("chatgpt-account-id"), acc_val));
// Cloudflare WAF は ChatGPT backend アクセス元を `originator` / // Cloudflare WAF は互換 backend アクセス元を `originator` /
// `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと // `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと
// HTML challenge (403) を返されて SSE に到達できない。 // HTML challenge (403) を返されて SSE に到達できない。
out.push(( out.push((
@ -188,10 +188,6 @@ impl AuthProvider for CodexAuthProvider {
.map_err(CodexAuthError::to_client_error)?; .map_err(CodexAuthError::to_client_error)?;
Self::build_headers(&snap).map_err(CodexAuthError::to_client_error) Self::build_headers(&snap).map_err(CodexAuthError::to_client_error)
} }
fn is_codex_backend(&self) -> bool {
true
}
} }
/// `access_token` の JWT `exp` を見て、期限切れなら true。 /// `access_token` の JWT `exp` を見て、期限切れなら true。

View File

@ -80,7 +80,7 @@ async fn response_with_timeout(
.await .await
.map_err(|_| { .map_err(|_| {
CodexAuthError::RefreshTransient(format!( CodexAuthError::RefreshTransient(format!(
"codex_oauth_refresh timed out after {}s", "oauth_token_refresh timed out after {}s",
timeout.as_secs() timeout.as_secs()
)) ))
})? })?

View File

@ -0,0 +1,4 @@
//! Optional built-in provider/backend helpers.
#[cfg(feature = "codex")]
pub mod codex;

View File

@ -130,13 +130,13 @@ mod tests {
let mut timeline = Timeline::new(); let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone()); timeline.on_tool_use_block(collector.clone());
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListWorkers")); timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListItems"));
timeline.dispatch(&Event::tool_use_stop(0)); timeline.dispatch(&Event::tool_use_stop(0));
let calls = collector.take_collected(); let calls = collector.take_collected();
assert_eq!(calls.len(), 1); assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "tool_empty"); assert_eq!(calls[0].id, "tool_empty");
assert_eq!(calls[0].name, "ListWorkers"); assert_eq!(calls[0].name, "ListItems");
assert!(calls[0].input.is_object()); assert!(calls[0].input.is_object());
assert_eq!( assert_eq!(
calls[0].input, calls[0].input,

View File

@ -18,4 +18,5 @@ toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
[dev-dependencies] [dev-dependencies]
serial_test = "3.4.0"
tempfile = { workspace = true } tempfile = { workspace = true }

View File

@ -723,7 +723,7 @@ fn resolve_auth_file(auth: &mut Option<AuthRef>, base: &Path) {
/// モデル宣言に含まれる `auth.file` が絶対パスであることを検証する。 /// モデル宣言に含まれる `auth.file` が絶対パスであることを検証する。
/// ref / scheme / model_id 等の論理的な有効性ref があるか、inline が /// ref / scheme / model_id 等の論理的な有効性ref があるか、inline が
/// 揃っているか)の検証はカタログを知る `crates/provider` 側で行う。 /// 揃っているか)の検証は [`crate::model_catalog`] で行う。
fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<(), ResolveError> { fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<(), ResolveError> {
if let Some(AuthRef::ApiKey { file: Some(p), .. }) = &model.auth { if let Some(AuthRef::ApiKey { file: Some(p), .. }) = &model.auth {
ensure_absolute(field, p)?; ensure_absolute(field, p)?;

View File

@ -1,6 +1,7 @@
mod config; mod config;
pub mod defaults; pub mod defaults;
mod model; mod model;
pub mod model_catalog;
pub mod paths; pub mod paths;
pub mod plugin; pub mod plugin;
mod profile; mod profile;

View File

@ -5,7 +5,7 @@
//! 直書き)の両方を受け入れるため、すべてのフィールドを `Option` として //! 直書き)の両方を受け入れるため、すべてのフィールドを `Option` として
//! 持つ 1 つの型 [`ModelManifest`] に統合している。実解決ref をプロバイダ //! 持つ 1 つの型 [`ModelManifest`] に統合している。実解決ref をプロバイダ
//! カタログ / モデルカタログから引いて `scheme` や `model_id` を埋める) //! カタログ / モデルカタログから引いて `scheme` や `model_id` を埋める)
//! は `crates/provider` の責務で、本モジュールはデータ表現のみを提供する。 //! は [`crate::model_catalog`] の責務で、本モジュールはデータ表現のみを提供する。
//! //!
//! 同じ型を partialカスケード層と完成形最終マニフェストの両方で //! 同じ型を partialカスケード層と完成形最終マニフェストの両方で
//! 使うことで、merge と最終変換の重複を避ける。 //! 使うことで、merge と最終変換の重複を避ける。
@ -24,7 +24,7 @@ pub use llm_engine::llm_client::capability::{ModelCapability, ReasoningControl,
/// - ref + 一部 override: ref で基底を引き、`auth` 等だけ書き換え /// - ref + 一部 override: ref で基底を引き、`auth` 等だけ書き換え
/// - 完全 inline: `ref` を省略して `scheme` / `model_id` / `auth` を直書き /// - 完全 inline: `ref` を省略して `scheme` / `model_id` / `auth` を直書き
/// ///
/// どの形が有効かの判定は `provider::resolve_model_manifest` が担う。 /// どの形が有効かの判定は [`crate::model_catalog::resolve_model_manifest`] が担う。
/// 本クレートは「どこから取るか」を表現するだけで、未設定かどうかを /// 本クレートは「どこから取るか」を表現するだけで、未設定かどうかを
/// 理由にした hard error は出さない。 /// 理由にした hard error は出さない。
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
@ -98,7 +98,7 @@ pub enum SchemeKind {
/// 認証の参照。 /// 認証の参照。
/// ///
/// 実際のトークン値の解決local secret store / file 読取、OAuth refresh 等)は /// 実際のトークン値の解決local secret store / file 読取、OAuth refresh 等)は
/// `crates/provider` で行う。ここはあくまで「どこから取るか」の宣言。 /// worker-side factory で行う。ここはあくまで「どこから取るか」の宣言。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum AuthRef { pub enum AuthRef {
@ -113,7 +113,7 @@ pub enum AuthRef {
#[serde(default)] #[serde(default)]
file: Option<PathBuf>, file: Option<PathBuf>,
}, },
/// ChatGPT OAuth`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット /// ChatGPT OAuth`~/.codex/auth.json`)。
#[serde(rename = "codex_oauth")] #[serde(rename = "codex_oauth")]
CodexOAuth, CodexOAuth,
/// Typed local secret-store reference. The profile resolver preserves this /// Typed local secret-store reference. The profile resolver preserves this

View File

@ -4,19 +4,19 @@
//! - builtin モデル: `resources/models/builtin.toml` //! - builtin モデル: `resources/models/builtin.toml`
//! - user override: `<config_dir>/{providers,models}.toml` //! - user override: `<config_dir>/{providers,models}.toml`
//! //!
//! `<config_dir>` の解決は [`manifest::paths::config_dir`] を参照。 //! `<config_dir>` の解決は [`crate::paths::config_dir`] を参照。
//! どちらの override も「あれば builtin を置換、無ければ builtin」と //! どちらの override も「あれば builtin を置換、無ければ builtin」と
//! いう一方向の差し替えマージしない。providers / models は独立に //! いう一方向の差し替えマージしない。providers / models は独立に
//! 読み、片方だけ user override も可。 //! 読み、片方だけ user override も可。
//! //!
//! [`resolve_model_manifest`] が `manifest::ModelManifest`ref / inline //! [`resolve_model_manifest`] が [`crate::ModelManifest`]ref / inline
//! 両形)を最終的な [`ModelConfig`] に解決する単一の入口で、wire 層 //! 両形)を最終的な [`ModelConfig`] に解決する単一の入口で、wire 層
//! に渡す前のバリデーションもここで行う。 //! に渡す前のバリデーションもここで行う。
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::{AuthRef, ModelManifest, SchemeKind};
use llm_engine::llm_client::capability::ModelCapability; use llm_engine::llm_client::capability::ModelCapability;
use manifest::{AuthRef, ModelManifest, SchemeKind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const BUILTIN_PROVIDERS: &str = include_str!("../../../resources/providers/builtin.toml"); const BUILTIN_PROVIDERS: &str = include_str!("../../../resources/providers/builtin.toml");
@ -178,7 +178,7 @@ fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef {
/// ユーザーが書いた設定が silent に無視されて builtin に戻る挙動は /// ユーザーが書いた設定が silent に無視されて builtin に戻る挙動は
/// 気付きにくいため)。 /// 気付きにくいため)。
pub fn load_providers() -> Result<Vec<ProviderEntry>, CatalogError> { pub fn load_providers() -> Result<Vec<ProviderEntry>, CatalogError> {
if let Some(path) = manifest::paths::user_catalog_override("providers.toml") if let Some(path) = crate::paths::user_catalog_override("providers.toml")
&& path.is_file() && path.is_file()
{ {
return load_providers_from(&path); return load_providers_from(&path);
@ -211,7 +211,7 @@ pub fn load_providers_from(path: &Path) -> Result<Vec<ProviderEntry>, CatalogErr
/// builtin + user override を解決してモデルカタログを返す。 /// builtin + user override を解決してモデルカタログを返す。
pub fn load_models() -> Result<Vec<ModelEntry>, CatalogError> { pub fn load_models() -> Result<Vec<ModelEntry>, CatalogError> {
if let Some(path) = manifest::paths::user_catalog_override("models.toml") if let Some(path) = crate::paths::user_catalog_override("models.toml")
&& path.is_file() && path.is_file()
{ {
return load_models_from(&path); return load_models_from(&path);

View File

@ -1,25 +0,0 @@
[package]
name = "provider"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
async-trait = { workspace = true }
base64 = "0.22.1"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
llm-engine = { workspace = true }
manifest = { workspace = true }
secrets = { workspace = true }
reqwest = { version = "0.13", features = ["json", "native-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "fs", "rt", "time"] }
toml = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
serial_test = "3.4.0"
tempfile = { workspace = true }
wiremock = "0.6.5"

View File

@ -1,33 +0,0 @@
# provider
## Role
`provider` resolves model/provider configuration and constructs provider-specific LLM clients for `llm-engine`.
## Boundaries
Owns:
- builtin and user provider/model catalog resolution
- model reference expansion into concrete model config
- auth reference resolution through supported mechanisms
- provider/scheme capability and context-window metadata
- provider-specific client construction
Does not own:
- Engine turn lifecycle (`llm-engine`)
- secret storage internals (`secrets`)
- Worker lifecycle (`worker`)
- product CLI parsing (`yoi`)
## Design notes
Provider API facts drift. Keep wire-format, auth, catalog, and capability differences here so Engine semantics remain stable.
Codex OAuth is a separate integration from normal provider secret refs because its local file shape and lifecycle differ.
## See also
- [`../../docs/design/provider-model-boundary.md`](../../docs/design/provider-model-boundary.md)
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)

View File

@ -23,7 +23,6 @@ manifest = { workspace = true }
secrets = { workspace = true } secrets = { workspace = true }
session-store = { workspace = true } session-store = { workspace = true }
fs4 = { workspace = true } fs4 = { workspace = true }
provider = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
minijinja = "2.19.0" minijinja = "2.19.0"

View File

@ -3,7 +3,7 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
use provider::catalog::{self, AuthHint, ModelEntry, ProviderEntry}; use manifest::model_catalog::{self as catalog, AuthHint, ModelEntry, ProviderEntry};
use toml::Value; use toml::Value;
use toml::map::Map; use toml::map::Map;

View File

@ -11,12 +11,12 @@ default = []
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
clap = { version = "4.6.0", features = ["derive"] } clap = { version = "4.6.0", features = ["derive"] }
llm-engine = { workspace = true } llm-engine = { workspace = true, features = ["codex"] }
session-store = { workspace = true } session-store = { workspace = true }
secrets = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
mcp = { workspace = true } mcp = { workspace = true }
protocol = { workspace = true } protocol = { workspace = true }
provider = { workspace = true }
client = { workspace = true } client = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
@ -46,6 +46,7 @@ futures-util = { version = "0.3", features = ["sink"] }
[dev-dependencies] [dev-dependencies]
dotenv = "0.15.0" dotenv = "0.15.0"
futures = { workspace = true } futures = { workspace = true }
serial_test = "3.4.0"
tempfile = { workspace = true } tempfile = { workspace = true }
wat = "1.241.2" wat = "1.241.2"
yoi-plugin-pdk = { workspace = true } yoi-plugin-pdk = { workspace = true }

View File

@ -1449,11 +1449,11 @@ where
let manifest = worker.manifest(); let manifest = worker.manifest();
// `build_client` がここに到達する前に同じマニフェストで成功している // `build_client` がここに到達する前に同じマニフェストで成功している
// ため、カタログ解決も必ず通る。念のため失敗時は "unknown" に落とす。 // ため、カタログ解決も必ず通る。念のため失敗時は "unknown" に落とす。
let resolved = provider::catalog::resolve_model_manifest(&manifest.model).ok(); let resolved = manifest::model_catalog::resolve_model_manifest(&manifest.model).ok();
let context_window = resolved let context_window = resolved
.as_ref() .as_ref()
.map(|cfg| cfg.context_window) .map(|cfg| cfg.context_window)
.unwrap_or(provider::catalog::DEFAULT_CONTEXT_WINDOW); .unwrap_or(manifest::model_catalog::DEFAULT_CONTEXT_WINDOW);
let (provider_name, model_id) = match resolved { let (provider_name, model_id) = match resolved {
Some(cfg) => { Some(cfg) => {
let name = match cfg.scheme { let name = match cfg.scheme {

View File

@ -8,6 +8,7 @@ pub mod fs_view;
pub mod hook; pub mod hook;
pub(crate) mod in_flight; pub(crate) mod in_flight;
pub mod ipc; pub mod ipc;
pub mod model_client;
pub mod prompt; pub mod prompt;
pub mod runtime; pub mod runtime;
pub mod segment_log_sink; pub mod segment_log_sink;
@ -30,11 +31,11 @@ pub use manifest::{
AuthRef, ModelManifest, SchemeKind, Scope, WorkerManifest, WorkerManifestConfig, AuthRef, ModelManifest, SchemeKind, Scope, WorkerManifest, WorkerManifestConfig,
WorkerMetaConfig, WorkerMetaConfig,
}; };
pub use model_client::{ProviderError, build_client};
pub use prompt::catalog::{CatalogError, PromptCatalog, WorkerPrompt}; pub use prompt::catalog::{CatalogError, PromptCatalog, WorkerPrompt};
pub use prompt::loader::PromptLoader; pub use prompt::loader::PromptLoader;
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus}; pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
pub use provider::{ProviderError, build_client};
pub use runtime::dir::RuntimeDir; pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink; pub use segment_log_sink::SegmentLogSink;
pub use shared_state::WorkerSharedState; pub use shared_state::WorkerSharedState;

View File

@ -1,5 +1,4 @@
//! Worker マニフェストの [`ModelManifest`] を [`Box<dyn LlmClient>`] //! [`ModelManifest`] を [`Box<dyn LlmClient>`] に落とす worker-side factory。
//! に落とすファクトリ。
//! //!
//! 段階: //! 段階:
//! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で //! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で
@ -11,11 +10,7 @@
//! `catalog::resolve_model_manifest` が [`ModelConfig`] に詰め込む) //! `catalog::resolve_model_manifest` が [`ModelConfig`] に詰め込む)
//! //!
//! llm-engine は低レベル基盤に留める方針なので、高レベル側で必要に //! llm-engine は低レベル基盤に留める方針なので、高レベル側で必要に
//! なる認証ストア解決Codex OAuth の `~/.codex/auth.json` 読取等)は //! なる認証ストア解決と secret store 解決は worker 側で行う。
//! このクレートに追加する。
pub mod catalog;
pub mod codex_oauth;
use std::sync::Arc; use std::sync::Arc;
@ -26,13 +21,14 @@ use llm_engine::llm_client::{
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme, Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
openai_responses::OpenAIResponsesScheme, openai_responses::OpenAIResponsesScheme,
}, },
transport::{HttpTransport, ResolvedAuth}, transport::{HttpTransport, ResolvedAuth, TransportPolicy},
}; };
use llm_engine::providers::codex::CodexAuthProvider;
use manifest::{AuthRef, ModelManifest, SchemeKind}; use manifest::{AuthRef, ModelManifest, SchemeKind, model_catalog as catalog};
use secrets::{SecretStore, SecretValue}; use secrets::{SecretStore, SecretValue};
pub use catalog::{ModelConfig, ResolveError as CatalogResolveError}; pub use manifest::model_catalog::{ModelConfig, ResolveError as CatalogResolveError};
/// プロバイダ構築時のエラー。 /// プロバイダ構築時のエラー。
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@ -111,7 +107,7 @@ fn resolve_auth_with_resolver(
Err(ProviderError::ApiKeyMissing { scheme }) Err(ProviderError::ApiKeyMissing { scheme })
} }
AuthRef::CodexOAuth => { AuthRef::CodexOAuth => {
let provider = codex_oauth::CodexAuthProvider::from_default_home() let provider = CodexAuthProvider::from_default_home()
.map_err(|e| ProviderError::Config(e.to_string()))?; .map_err(|e| ProviderError::Config(e.to_string()))?;
Ok(ResolvedAuth::Custom(Arc::new(provider))) Ok(ResolvedAuth::Custom(Arc::new(provider)))
} }
@ -144,6 +140,7 @@ fn build_transport<S: Scheme>(
scheme: S, scheme: S,
config: &ModelConfig, config: &ModelConfig,
resolved: ResolvedAuth, resolved: ResolvedAuth,
policy: TransportPolicy,
) -> Result<Box<dyn LlmClient>, ProviderError> { ) -> Result<Box<dyn LlmClient>, ProviderError> {
if !resolved.matches(scheme.required_auth()) { if !resolved.matches(scheme.required_auth()) {
return Err(ProviderError::AuthMismatch { return Err(ProviderError::AuthMismatch {
@ -160,21 +157,39 @@ fn build_transport<S: Scheme>(
.clone() .clone()
.unwrap_or_else(|| scheme.default_capability()); .unwrap_or_else(|| scheme.default_capability());
let base_url = effective_base_url(&scheme, config); let base_url = effective_base_url(&scheme, config);
Ok(Box::new(HttpTransport::new( Ok(Box::new(
scheme, HttpTransport::new(
config.model_id.clone(), scheme,
base_url, config.model_id.clone(),
resolved, base_url,
capability, resolved,
))) capability,
)
.with_transport_policy(policy),
))
} }
fn build_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, ProviderError> { fn build_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, ProviderError> {
let resolved = resolve_auth(config.scheme, &config.auth)?; let resolved = resolve_auth(config.scheme, &config.auth)?;
match config.scheme { match config.scheme {
SchemeKind::Anthropic => build_transport(AnthropicScheme::new(), config, resolved), SchemeKind::Anthropic => build_transport(
SchemeKind::OpenaiChat => build_transport(OpenAIScheme::new(), config, resolved), AnthropicScheme::new(),
SchemeKind::Gemini => build_transport(GeminiScheme::new(), config, resolved), config,
resolved,
TransportPolicy::standard(),
),
SchemeKind::OpenaiChat => build_transport(
OpenAIScheme::new(),
config,
resolved,
TransportPolicy::standard(),
),
SchemeKind::Gemini => build_transport(
GeminiScheme::new(),
config,
resolved,
TransportPolicy::standard(),
),
SchemeKind::OpenaiResponses => { SchemeKind::OpenaiResponses => {
// ChatGPT backend (codex-oauth) は `max_output_tokens` / // ChatGPT backend (codex-oauth) は `max_output_tokens` /
// `temperature` / `top_p` を 400 で弾くため、その経路では // `temperature` / `top_p` を 400 で弾くため、その経路では
@ -183,7 +198,12 @@ fn build_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, Provide
let scheme = OpenAIResponsesScheme::new() let scheme = OpenAIResponsesScheme::new()
.with_send_max_output_tokens(send_to_official) .with_send_max_output_tokens(send_to_official)
.with_send_sampling_params(send_to_official); .with_send_sampling_params(send_to_official);
build_transport(scheme, config, resolved) let policy = if matches!(config.auth, AuthRef::CodexOAuth) {
TransportPolicy::openai_compatible_zstd()
} else {
TransportPolicy::standard()
};
build_transport(scheme, config, resolved, policy)
} }
} }
} }
@ -208,10 +228,34 @@ mod tests {
use super::*; use super::*;
use serial_test::serial; use serial_test::serial;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::{Path, PathBuf};
struct TestSecrets(std::collections::BTreeMap<String, String>); struct TestSecrets(std::collections::BTreeMap<String, String>);
struct ConfigDirGuard {
prev: Option<String>,
}
impl ConfigDirGuard {
fn new(path: &Path) -> Self {
let prev = std::env::var("YOI_CONFIG_DIR").ok();
// SAFETY: tests using this guard are marked `#[serial]`.
unsafe { std::env::set_var("YOI_CONFIG_DIR", path) };
Self { prev }
}
}
impl Drop for ConfigDirGuard {
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var("YOI_CONFIG_DIR", v),
None => std::env::remove_var("YOI_CONFIG_DIR"),
}
}
}
}
impl SecretResolver for TestSecrets { impl SecretResolver for TestSecrets {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> { fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
self.0 self.0
@ -311,7 +355,11 @@ mod tests {
} }
#[test] #[test]
#[serial]
fn ref_manifest_builds_client() { fn ref_manifest_builds_client() {
let dir = tempfile::tempdir().unwrap();
let _guard = ConfigDirGuard::new(dir.path());
// Ollama は AuthRef::None で構築できる end-to-end path。 // Ollama は AuthRef::None で構築できる end-to-end path。
let manifest = ModelManifest { let manifest = ModelManifest {
ref_: Some("ollama-local/llama3.1".into()), ref_: Some("ollama-local/llama3.1".into()),
@ -326,7 +374,11 @@ mod tests {
} }
#[test] #[test]
#[serial]
fn inline_manifest_builds_client() { fn inline_manifest_builds_client() {
let dir = tempfile::tempdir().unwrap();
let _guard = ConfigDirGuard::new(dir.path());
// Form C: 完全直書き。Ollama 相当を AuthRef::None で構築。 // Form C: 完全直書き。Ollama 相当を AuthRef::None で構築。
let manifest = ModelManifest { let manifest = ModelManifest {
scheme: Some(SchemeKind::Anthropic), scheme: Some(SchemeKind::Anthropic),

View File

@ -3048,7 +3048,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
fn build_compactor_client(&self) -> Result<Box<dyn LlmClient>, WorkerError> { fn build_compactor_client(&self) -> Result<Box<dyn LlmClient>, WorkerError> {
if let Some(ref compaction) = self.manifest.compaction { if let Some(ref compaction) = self.manifest.compaction {
if let Some(ref model_config) = compaction.model { if let Some(ref model_config) = compaction.model {
let client = provider::build_client(model_config)?; let client = crate::model_client::build_client(model_config)?;
return Ok(client); return Ok(client);
} }
} }
@ -3065,7 +3065,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
memory_cfg: &manifest::MemoryConfig, memory_cfg: &manifest::MemoryConfig,
) -> Result<Box<dyn LlmClient>, WorkerError> { ) -> Result<Box<dyn LlmClient>, WorkerError> {
if let Some(ref m) = memory_cfg.extract_model { if let Some(ref m) = memory_cfg.extract_model {
let client = provider::build_client(m)?; let client = crate::model_client::build_client(m)?;
return Ok(client); return Ok(client);
} }
let worker = self.engine.as_ref().expect("worker taken during run"); let worker = self.engine.as_ref().expect("worker taken during run");
@ -3502,7 +3502,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
memory_cfg: &manifest::MemoryConfig, memory_cfg: &manifest::MemoryConfig,
) -> Result<Box<dyn LlmClient>, WorkerError> { ) -> Result<Box<dyn LlmClient>, WorkerError> {
if let Some(ref m) = memory_cfg.consolidation_model { if let Some(ref m) = memory_cfg.consolidation_model {
let client = provider::build_client(m)?; let client = crate::model_client::build_client(m)?;
return Ok(client); return Ok(client);
} }
let worker = self.engine.as_ref().expect("worker taken during run"); let worker = self.engine.as_ref().expect("worker taken during run");
@ -5070,7 +5070,7 @@ pub enum WorkerError {
ManifestResolve(#[source] ResolveError), ManifestResolve(#[source] ResolveError),
#[error(transparent)] #[error(transparent)]
Provider(#[from] provider::ProviderError), Provider(#[from] crate::model_client::ProviderError),
#[error("compaction thrash: context still exceeds threshold immediately after compact")] #[error("compaction thrash: context still exceeds threshold immediately after compact")]
CompactThrash, CompactThrash,
@ -5299,7 +5299,7 @@ fn prepare_worker_common_from_scope(
let delegation_scope = let delegation_scope =
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?; DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
let client = provider::build_client(&manifest.model)?; let client = crate::model_client::build_client(&manifest.model)?;
let prompts = PromptCatalog::load(loader, manifest.worker.prompt_pack.as_deref())?; let prompts = PromptCatalog::load(loader, manifest.worker.prompt_pack.as_deref())?;
let memory_layout = manifest.memory.as_ref().and_then(|mem| { let memory_layout = manifest.memory.as_ref().and_then(|mem| {
filesystem_authority filesystem_authority

View File

@ -43,7 +43,7 @@ name = "example-agent"
# (b) `ref` + 一部 override — auth など個別差し替え # (b) `ref` + 一部 override — auth など個別差し替え
# (c) `scheme` + `model_id` 直書き — カタログを使わない inline 指定 # (c) `scheme` + `model_id` 直書き — カタログを使わない inline 指定
# (b) / (c) では `ref` 未指定なら `scheme` / `model_id` / `auth` が必須。 # (b) / (c) では `ref` 未指定なら `scheme` / `model_id` / `auth` が必須。
# (実際の必須判定は `crates/provider` の resolve 側で行う) # (実際の必須判定は manifest::model_catalog の resolve 側で行う)
[model] [model]
# 任意。形式: "<provider_id>/<model_id_in_ref>"。 # 任意。形式: "<provider_id>/<model_id_in_ref>"。