1#![allow(dead_code)]
2
3use std::sync::Arc;
13
14use async_trait::async_trait;
15use schemars::JsonSchema;
16use secrecy::SecretString;
17use serde::{Deserialize, Serialize};
18
19use super::memory::MemoryRunStore;
20use crate::component::AnyComponent;
21use crate::component::{Component, ComponentContext};
22use crate::context::ContextPipeline;
23use crate::factory_registry::{FactoryError, FactoryRegistry};
24use crate::registry::TypedAnyComponent;
25#[cfg(feature = "anthropic")]
26use behest_adapter_anthropic::chat::AnthropicChatAdapter;
27#[cfg(feature = "openai")]
28use behest_adapter_openai::chat::OpenAiChatAdapter;
29#[cfg(feature = "openai")]
30use behest_adapter_openai::embed::OpenAiEmbeddingAdapter;
31use behest_core::error::ProviderError;
32#[cfg(any(feature = "openai", feature = "anthropic"))]
33use behest_provider::ChatProvider;
34#[cfg(feature = "openai")]
35use behest_provider::EmbeddingProvider;
36use behest_provider::config::{DEFAULT_CONNECT_TIMEOUT, DEFAULT_TIMEOUT};
37use behest_provider::{ProviderHttpConfig, ProviderId};
38use behest_store::memory::{
39 MemoryArtifactStore, MemoryEmbeddingStore, MemoryExecutionStore, MemorySessionStore,
40};
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
50pub struct ProviderHttpComponentConfig {
51 pub id: String,
53 pub base_url: String,
55 pub api_key: Option<String>,
57 pub organization: Option<String>,
59 pub timeout_secs: Option<u64>,
61 pub connect_timeout_secs: Option<u64>,
63 pub max_retries: Option<usize>,
65}
66
67impl ProviderHttpComponentConfig {
68 #[must_use]
71 pub fn into_provider_http_config(self) -> ProviderHttpConfig {
72 let mut cfg = ProviderHttpConfig::new(ProviderId::new(&self.id), self.base_url);
73 if let Some(key) = self.api_key {
74 cfg = cfg.with_api_key(SecretString::new(key.into()));
75 }
76 if let Some(org) = self.organization {
77 cfg = cfg.with_organization(org);
78 }
79 let timeout = self
80 .timeout_secs
81 .map_or(DEFAULT_TIMEOUT, std::time::Duration::from_secs);
82 let connect_timeout = self
83 .connect_timeout_secs
84 .map_or(DEFAULT_CONNECT_TIMEOUT, std::time::Duration::from_secs);
85 cfg = cfg.with_timeouts(timeout, connect_timeout);
86 cfg = cfg.with_max_retries(self.max_retries.unwrap_or(2));
87 cfg
88 }
89}
90
91#[derive(Debug, thiserror::Error)]
97pub enum ComponentError {
98 #[error("provider error: {0}")]
100 Provider(String),
101 #[error("store error: {0}")]
103 Store(String),
104 #[error("context error: {0}")]
106 Context(String),
107}
108
109impl From<ProviderError> for ComponentError {
110 fn from(e: ProviderError) -> Self {
111 Self::Provider(e.to_string())
112 }
113}
114
115#[cfg(any(feature = "openai", feature = "anthropic"))]
121macro_rules! provider_component {
122 (
123 $(#[$outer:meta])*
124 $vis:vis struct $Name:ident {
125 inner: Arc<$InnerTrait:ty>,
126 },
127 const NAME: &'static str = $name:literal;
128 adapter = $Adapter:ty;
129 ) => {
130 $(#[$outer])*
131 $vis struct $Name {
132 inner: Arc<$InnerTrait>,
133 }
134
135 #[async_trait]
136 impl Component for $Name {
137 const NAME: &'static str = $name;
138 type Config = ProviderHttpComponentConfig;
139 type Error = ComponentError;
140
141 async fn init(cfg: &Self::Config, _ctx: &ComponentContext) -> Result<Self, Self::Error> {
142 let http = cfg.clone().into_provider_http_config();
143 let adapter = <$Adapter>::new(http)?;
144 Ok(Self {
145 inner: Arc::new(adapter),
146 })
147 }
148 }
149 };
150}
151
152#[cfg(feature = "openai")]
153provider_component! {
154 pub struct OpenAiChatComponent {
156 inner: Arc<dyn ChatProvider>,
157 },
158 const NAME: &'static str = "provider.openai.chat";
159 adapter = OpenAiChatAdapter;
160}
161
162#[cfg(feature = "anthropic")]
163provider_component! {
164 pub struct AnthropicChatComponent {
166 inner: Arc<dyn ChatProvider>,
167 },
168 const NAME: &'static str = "provider.anthropic.chat";
169 adapter = AnthropicChatAdapter;
170}
171
172#[cfg(feature = "openai")]
173provider_component! {
174 pub struct OpenAiEmbeddingComponent {
176 inner: Arc<dyn EmbeddingProvider>,
177 },
178 const NAME: &'static str = "provider.openai.embedding";
179 adapter = OpenAiEmbeddingAdapter;
180}
181
182macro_rules! memory_store_component {
192 (
193 $(#[$outer:meta])*
194 $vis:vis struct $Name:ident {
195 inner: Arc<$Store:ty>,
196 },
197 const NAME: &'static str = $name:literal;
198 ) => {
199 $(#[$outer])*
200 $vis struct $Name {
201 inner: Arc<$Store>,
202 }
203
204 #[async_trait]
205 impl Component for $Name {
206 const NAME: &'static str = $name;
207 type Config = serde_json::Value;
208 type Error = ComponentError;
209
210 async fn init(_cfg: &Self::Config, _ctx: &ComponentContext) -> Result<Self, Self::Error> {
211 Ok(Self {
212 inner: Arc::new(<$Store>::new()),
213 })
214 }
215 }
216 };
217}
218
219memory_store_component! {
220 pub struct MemorySessionStoreComponent {
222 inner: Arc<MemorySessionStore>,
223 },
224 const NAME: &'static str = "store.session.memory";
225}
226
227memory_store_component! {
228 pub struct MemoryExecutionStoreComponent {
230 inner: Arc<MemoryExecutionStore>,
231 },
232 const NAME: &'static str = "store.execution.memory";
233}
234
235memory_store_component! {
236 pub struct MemoryRunStoreComponent {
238 inner: Arc<MemoryRunStore>,
239 },
240 const NAME: &'static str = "store.run.memory";
241}
242
243memory_store_component! {
244 pub struct MemoryEmbeddingStoreComponent {
246 inner: Arc<MemoryEmbeddingStore>,
247 },
248 const NAME: &'static str = "store.embedding.memory";
249}
250
251memory_store_component! {
252 pub struct MemoryArtifactStoreComponent {
254 inner: Arc<MemoryArtifactStore>,
255 },
256 const NAME: &'static str = "store.artifact.memory";
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
265pub struct ContextPipelineConfig {
266 pub max_history_messages: Option<usize>,
268 pub max_history_tokens: Option<usize>,
270 pub enable_compaction_filter: Option<bool>,
272}
273
274pub struct ContextPipelineComponent {
276 inner: Arc<ContextPipeline>,
277}
278
279#[async_trait]
280impl Component for ContextPipelineComponent {
281 const NAME: &'static str = "context.pipeline";
282 type Config = ContextPipelineConfig;
283 type Error = ComponentError;
284
285 async fn init(cfg: &Self::Config, _ctx: &ComponentContext) -> Result<Self, Self::Error> {
286 let mut pipeline = ContextPipeline::new();
287 if let Some(v) = cfg.max_history_messages {
288 pipeline = pipeline.with_max_history(v);
289 }
290 if let Some(v) = cfg.max_history_tokens {
291 pipeline = pipeline.with_max_history_tokens(v);
292 }
293 if let Some(v) = cfg.enable_compaction_filter {
294 pipeline = pipeline.with_compaction_filter(v);
295 }
296 Ok(Self {
297 inner: Arc::new(pipeline),
298 })
299 }
300}
301
302#[must_use]
313pub fn register_providers(registry: FactoryRegistry) -> FactoryRegistry {
314 #[cfg(any(feature = "openai", feature = "anthropic"))]
315 {
316 let mut registry = registry;
317 macro_rules! register {
318 ($kind:literal, $Comp:ident, $Adapter:ty) => {
319 registry = registry.register($kind, |cfg, _ctx| {
320 let v: ProviderHttpComponentConfig =
321 serde_json::from_value(cfg).map_err(|e| FactoryError::InvalidConfig {
322 kind: $kind.into(),
323 source: e,
324 })?;
325 let http = v.into_provider_http_config();
326 let adapter = <$Adapter>::new(http)
327 .map_err(|e| FactoryError::FactoryFailed($kind.into(), e.to_string()))?;
328 let comp = $Comp {
329 inner: Arc::new(adapter),
330 };
331 Ok(Box::new(TypedAnyComponent::new(comp)) as Box<dyn AnyComponent>)
332 });
333 };
334 }
335
336 #[cfg(feature = "openai")]
337 {
338 register!(
339 "provider.openai.chat",
340 OpenAiChatComponent,
341 OpenAiChatAdapter
342 );
343 register!(
344 "provider.openai.embedding",
345 OpenAiEmbeddingComponent,
346 OpenAiEmbeddingAdapter
347 );
348 }
349 #[cfg(feature = "anthropic")]
350 {
351 register!(
352 "provider.anthropic.chat",
353 AnthropicChatComponent,
354 AnthropicChatAdapter
355 );
356 }
357
358 registry
359 }
360
361 #[cfg(not(any(feature = "openai", feature = "anthropic")))]
362 {
363 registry
364 }
365}
366
367#[must_use]
376pub fn register_memory_stores(mut registry: FactoryRegistry) -> FactoryRegistry {
377 macro_rules! register {
378 ($kind:literal, $Comp:ident, $Store:ty) => {
379 registry = registry.register($kind, |cfg, ctx| {
380 let _ = (cfg, &ctx);
381 let comp = $Comp {
382 inner: Arc::new(<$Store>::new()),
383 };
384 Ok(Box::new(TypedAnyComponent::new(comp)) as Box<dyn AnyComponent>)
385 });
386 };
387 }
388 register!(
389 "store.session.memory",
390 MemorySessionStoreComponent,
391 MemorySessionStore
392 );
393 register!(
394 "store.execution.memory",
395 MemoryExecutionStoreComponent,
396 MemoryExecutionStore
397 );
398 register!("store.run.memory", MemoryRunStoreComponent, MemoryRunStore);
399 register!(
400 "store.embedding.memory",
401 MemoryEmbeddingStoreComponent,
402 MemoryEmbeddingStore
403 );
404 register!(
405 "store.artifact.memory",
406 MemoryArtifactStoreComponent,
407 MemoryArtifactStore
408 );
409 registry
410}
411
412#[must_use]
417pub fn register_context_pipeline(registry: FactoryRegistry) -> FactoryRegistry {
418 registry.register("context.pipeline", |cfg, _ctx| {
419 let v: ContextPipelineConfig =
420 serde_json::from_value(cfg).map_err(|e| FactoryError::InvalidConfig {
421 kind: "context.pipeline".into(),
422 source: e,
423 })?;
424 let mut pipeline = ContextPipeline::new();
425 if let Some(v) = v.max_history_messages {
426 pipeline = pipeline.with_max_history(v);
427 }
428 if let Some(v) = v.max_history_tokens {
429 pipeline = pipeline.with_max_history_tokens(v);
430 }
431 if let Some(v) = v.enable_compaction_filter {
432 pipeline = pipeline.with_compaction_filter(v);
433 }
434 let comp = ContextPipelineComponent {
435 inner: Arc::new(pipeline),
436 };
437 Ok(Box::new(TypedAnyComponent::new(comp)) as Box<dyn AnyComponent>)
438 })
439}
440
441#[must_use]
448pub fn default_factory_registry() -> FactoryRegistry {
449 let reg = FactoryRegistry::new();
450 let reg = register_providers(reg);
451 let reg = register_memory_stores(reg);
452 register_context_pipeline(reg)
453}
454
455#[cfg(test)]
456mod tests {
457 #![allow(clippy::expect_used)]
458 use super::*;
459 use crate::factory_registry::FactoryError;
460 use crate::lifecycle::ShutdownToken;
461
462 fn ctx() -> ComponentContext {
463 ComponentContext::new(ShutdownToken::new())
464 }
465
466 #[test]
467 fn default_registry_contains_all_kinds() {
468 let reg = default_factory_registry();
469 let kinds: Vec<&str> = reg.kinds().collect();
470 assert!(kinds.contains(&"store.session.memory"));
471 assert!(kinds.contains(&"store.execution.memory"));
472 assert!(kinds.contains(&"store.run.memory"));
473 assert!(kinds.contains(&"store.embedding.memory"));
474 assert!(kinds.contains(&"store.artifact.memory"));
475 assert!(kinds.contains(&"context.pipeline"));
476
477 let expected_providers: usize =
478 usize::from(cfg!(feature = "openai")) * 2 + usize::from(cfg!(feature = "anthropic"));
479 assert_eq!(kinds.len(), 6 + expected_providers);
480
481 #[cfg(feature = "openai")]
482 {
483 assert!(kinds.contains(&"provider.openai.chat"));
484 assert!(kinds.contains(&"provider.openai.embedding"));
485 }
486 #[cfg(feature = "anthropic")]
487 {
488 assert!(kinds.contains(&"provider.anthropic.chat"));
489 }
490 }
491
492 #[test]
493 fn default_registry_rejects_unknown_kind() {
494 let reg = default_factory_registry();
495 let result = reg.invoke("nope", serde_json::json!({}), &ctx());
496 assert!(matches!(result, Err(FactoryError::UnknownKind(_))));
497 }
498
499 #[test]
500 fn memory_store_invocations() {
501 for kind in &[
502 "store.session.memory",
503 "store.execution.memory",
504 "store.run.memory",
505 "store.embedding.memory",
506 "store.artifact.memory",
507 ] {
508 let reg = default_factory_registry();
509 let comp = reg
510 .invoke(kind, serde_json::json!({}), &ctx())
511 .expect("invoke should succeed");
512 assert_eq!(comp.name(), *kind);
513 }
514 }
515
516 #[tokio::test]
517 async fn context_pipeline_invocation() {
518 let reg = default_factory_registry();
519 let comp = reg
520 .invoke(
521 "context.pipeline",
522 serde_json::json!({
523 "max_history_messages": 100,
524 }),
525 &ctx(),
526 )
527 .expect("invoke should succeed");
528 assert_eq!(comp.name(), "context.pipeline");
529 }
530
531 #[cfg(feature = "openai")]
532 #[test]
533 fn provider_openai_chat_invocation_succeeds_without_api_key() {
534 let reg = default_factory_registry();
537 let result = reg.invoke(
538 "provider.openai.chat",
539 serde_json::json!({
540 "id": "test",
541 "base_url": "https://api.openai.com/v1",
542 }),
543 &ctx(),
544 );
545 assert!(result.is_ok());
546 }
547
548 #[cfg(feature = "openai")]
549 #[test]
550 fn provider_openai_chat_invocation_fails_with_bad_config() {
551 let reg = default_factory_registry();
552 let result = reg.invoke(
553 "provider.openai.chat",
554 serde_json::json!({ "bad_field": true }),
555 &ctx(),
556 );
557 assert!(matches!(result, Err(FactoryError::InvalidConfig { .. })));
558 }
559
560 #[test]
561 fn register_returns_chained_registry() {
562 let reg = FactoryRegistry::new();
563 let reg = register_memory_stores(reg);
564 assert!(reg.contains("store.session.memory"));
565 assert!(reg.contains("store.execution.memory"));
566 }
567
568 #[test]
569 fn register_providers_returns_chained_registry() {
570 let reg = FactoryRegistry::new();
571 let reg = register_providers(reg);
572 #[cfg(feature = "openai")]
573 {
574 assert!(reg.contains("provider.openai.chat"));
575 assert!(reg.contains("provider.openai.embedding"));
576 }
577 #[cfg(feature = "anthropic")]
578 {
579 assert!(reg.contains("provider.anthropic.chat"));
580 }
581 }
582
583 #[test]
584 fn register_context_pipeline_returns_chained_registry() {
585 let reg = FactoryRegistry::new();
586 let reg = register_context_pipeline(reg);
587 assert!(reg.contains("context.pipeline"));
588 }
589
590 #[test]
591 fn provider_http_component_config_roundtrip() {
592 let json = serde_json::json!({
593 "id": "my-openai",
594 "base_url": "https://api.openai.com/v1",
595 "api_key": "sk-test123",
596 "organization": "org-abc",
597 "timeout_secs": 30,
598 "connect_timeout_secs": 5,
599 "max_retries": 3,
600 });
601 let cfg: ProviderHttpComponentConfig =
602 serde_json::from_value(json).expect("deserialize should succeed");
603 assert_eq!(cfg.id, "my-openai");
604 assert_eq!(cfg.base_url, "https://api.openai.com/v1");
605 assert_eq!(cfg.api_key.as_deref(), Some("sk-test123"));
606 assert_eq!(cfg.organization.as_deref(), Some("org-abc"));
607 assert_eq!(cfg.timeout_secs, Some(30));
608 assert_eq!(cfg.connect_timeout_secs, Some(5));
609 assert_eq!(cfg.max_retries, Some(3));
610
611 let http = cfg.into_provider_http_config();
612 assert_eq!(http.id.as_str(), "my-openai");
613 assert_eq!(http.base_url, "https://api.openai.com/v1");
614 }
615
616 #[test]
617 fn provider_http_component_config_defaults() {
618 let json = serde_json::json!({
619 "id": "minimal",
620 "base_url": "https://example.com",
621 });
622 let cfg: ProviderHttpComponentConfig =
623 serde_json::from_value(json).expect("deserialize should succeed");
624 assert_eq!(cfg.id, "minimal");
625 assert!(cfg.api_key.is_none());
626 assert!(cfg.organization.is_none());
627
628 let http = cfg.into_provider_http_config();
629 assert_eq!(http.max_retries, 2);
630 }
631
632 #[test]
633 fn context_pipeline_config_roundtrip() {
634 let json = serde_json::json!({
635 "max_history_messages": 100,
636 "max_history_tokens": 128_000,
637 "enable_compaction_filter": false,
638 });
639 let cfg: ContextPipelineConfig =
640 serde_json::from_value(json).expect("deserialize should succeed");
641 assert_eq!(cfg.max_history_messages, Some(100));
642 assert_eq!(cfg.max_history_tokens, Some(128_000));
643 assert_eq!(cfg.enable_compaction_filter, Some(false));
644 }
645}