use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use clawft_types::provider::LlmResponse;
use clawft_types::routing::AuthContext;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
pub messages: Vec<LlmMessage>,
#[serde(default)]
pub tools: Vec<serde_json::Value>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub max_tokens: Option<i32>,
#[serde(default)]
pub temperature: Option<f64>,
#[serde(default, skip_deserializing)]
pub auth_context: Option<AuthContext>,
#[serde(default)]
pub complexity_boost: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmMessage {
pub role: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone)]
pub struct TaskProfile {
pub task_type: TaskType,
pub complexity: f32,
pub keywords: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TaskType {
Chat,
CodeGeneration,
CodeReview,
Research,
Creative,
Analysis,
ToolUse,
Unknown,
}
#[derive(Debug, Clone, Default)]
pub struct RoutingDecision {
pub provider: String,
pub model: String,
pub reason: String,
pub tier: Option<String>,
pub cost_estimate_usd: Option<f64>,
pub escalated: bool,
pub budget_constrained: bool,
pub sender_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResponseOutcome {
pub success: bool,
pub quality: QualityScore,
pub latency_ms: u64,
}
#[derive(Debug, Clone)]
pub struct QualityScore {
pub overall: f32,
pub relevance: f32,
pub coherence: f32,
}
#[derive(Debug, Clone)]
pub struct AssembledContext {
pub messages: Vec<LlmMessage>,
pub token_estimate: usize,
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub struct TransportRequest {
pub provider: String,
pub model: String,
pub messages: Vec<LlmMessage>,
pub tools: Vec<serde_json::Value>,
pub max_tokens: Option<i32>,
pub temperature: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct Trajectory {
pub request: ChatRequest,
pub routing: RoutingDecision,
pub response: LlmResponse,
pub quality: QualityScore,
}
#[derive(Debug, Clone)]
pub struct LearningSignal {
pub feedback_type: String,
pub value: f32,
}
pub trait TaskClassifier: Send + Sync {
fn classify(&self, request: &ChatRequest) -> TaskProfile;
}
#[async_trait]
pub trait ModelRouter: Send + Sync {
async fn route(&self, request: &ChatRequest, profile: &TaskProfile) -> RoutingDecision;
fn update(&self, decision: &RoutingDecision, outcome: &ResponseOutcome);
}
#[async_trait]
pub trait ContextAssembler: Send + Sync {
async fn assemble(&self, request: &ChatRequest, profile: &TaskProfile) -> AssembledContext;
}
pub type StreamCallback = Box<dyn FnMut(&str) -> bool + Send>;
#[async_trait]
pub trait LlmTransport: Send + Sync {
async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse>;
async fn complete_stream(
&self,
request: &TransportRequest,
mut callback: StreamCallback,
) -> clawft_types::Result<LlmResponse> {
let response = self.complete(request).await?;
for block in &response.content {
if let clawft_types::provider::ContentBlock::Text { text } = block
&& !callback(text)
{
break;
}
}
Ok(response)
}
}
pub trait QualityScorer: Send + Sync {
fn score(&self, request: &ChatRequest, response: &LlmResponse) -> QualityScore;
}
pub trait LearningBackend: Send + Sync {
fn record(&self, trajectory: &Trajectory);
fn adapt(&self, signal: &LearningSignal);
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum BudgetResult {
Approved,
DailyLimitExceeded { spent: f64, limit: f64 },
MonthlyLimitExceeded { spent: f64, limit: f64 },
}
impl BudgetResult {
pub fn is_approved(&self) -> bool {
matches!(self, BudgetResult::Approved)
}
}
pub trait CostTrackable: Send + Sync {
fn check_budget(
&self,
sender_id: &str,
estimated_cost: f64,
daily_limit: f64,
monthly_limit: f64,
) -> BudgetResult;
fn record_estimated(&self, sender_id: &str, estimated_cost: f64);
fn record_actual(&self, sender_id: &str, estimated_cost: f64, actual_cost: f64);
}
pub trait RateLimitable: Send + Sync {
fn check(&self, sender_id: &str, limit: u32) -> bool;
}
pub struct Pipeline {
pub classifier: Arc<dyn TaskClassifier>,
pub router: Arc<dyn ModelRouter>,
pub assembler: Arc<dyn ContextAssembler>,
pub transport: Arc<dyn LlmTransport>,
pub scorer: Arc<dyn QualityScorer>,
pub learner: Arc<dyn LearningBackend>,
}
pub struct PipelineRegistry {
pipelines: HashMap<TaskType, Pipeline>,
default: Pipeline,
}
impl PipelineRegistry {
pub fn new(default: Pipeline) -> Self {
Self {
pipelines: HashMap::new(),
default,
}
}
pub fn register(&mut self, task_type: TaskType, pipeline: Pipeline) {
self.pipelines.insert(task_type, pipeline);
}
pub fn get(&self, task_type: &TaskType) -> &Pipeline {
self.pipelines.get(task_type).unwrap_or(&self.default)
}
pub async fn complete(&self, request: &ChatRequest) -> clawft_types::Result<LlmResponse> {
let profile = self.default.classifier.classify(request);
let pipeline = self.get(&profile.task_type);
let routing = pipeline.router.route(request, &profile).await;
let context = pipeline.assembler.assemble(request, &profile).await;
let transport_request = TransportRequest {
provider: routing.provider.clone(),
model: routing.model.clone(),
messages: context.messages,
tools: request.tools.clone(),
max_tokens: request.max_tokens,
temperature: request.temperature,
};
let start_ms = crate::runtime::now_millis();
let response = pipeline.transport.complete(&transport_request).await?;
let latency_ms = crate::runtime::now_millis().saturating_sub(start_ms);
let quality = pipeline.scorer.score(request, &response);
let trajectory = Trajectory {
request: request.clone(),
routing: routing.clone(),
response: response.clone(),
quality,
};
pipeline.learner.record(&trajectory);
let outcome = ResponseOutcome {
success: true,
quality: trajectory.quality,
latency_ms,
};
pipeline.router.update(&routing, &outcome);
Ok(response)
}
pub async fn complete_stream(
&self,
request: &ChatRequest,
callback: StreamCallback,
) -> clawft_types::Result<LlmResponse> {
let profile = self.default.classifier.classify(request);
let pipeline = self.get(&profile.task_type);
let routing = pipeline.router.route(request, &profile).await;
let context = pipeline.assembler.assemble(request, &profile).await;
let transport_request = TransportRequest {
provider: routing.provider.clone(),
model: routing.model.clone(),
messages: context.messages,
tools: request.tools.clone(),
max_tokens: request.max_tokens,
temperature: request.temperature,
};
let start_ms = crate::runtime::now_millis();
let response = pipeline
.transport
.complete_stream(&transport_request, callback)
.await?;
let latency_ms = crate::runtime::now_millis().saturating_sub(start_ms);
let quality = pipeline.scorer.score(request, &response);
let trajectory = Trajectory {
request: request.clone(),
routing: routing.clone(),
response: response.clone(),
quality,
};
pipeline.learner.record(&trajectory);
let outcome = ResponseOutcome {
success: true,
quality: trajectory.quality,
latency_ms,
};
pipeline.router.update(&routing, &outcome);
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clawft_types::provider::{ContentBlock, StopReason, Usage};
#[test]
fn chat_request_construction() {
let req = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: Some("gpt-4o".into()),
max_tokens: Some(1024),
temperature: Some(0.7),
auth_context: None,
complexity_boost: 0.0,
};
assert_eq!(req.messages.len(), 1);
assert_eq!(req.model.as_deref(), Some("gpt-4o"));
}
#[test]
fn llm_message_with_tool_call_id() {
let msg = LlmMessage {
role: "tool".into(),
content: "result data".into(),
tool_call_id: Some("call-123".into()),
tool_calls: None,
};
assert_eq!(msg.tool_call_id.as_deref(), Some("call-123"));
}
#[test]
fn task_profile_construction() {
let profile = TaskProfile {
task_type: TaskType::CodeGeneration,
complexity: 0.7,
keywords: vec!["implement".into(), "function".into()],
};
assert_eq!(profile.task_type, TaskType::CodeGeneration);
assert!(profile.complexity > 0.5);
assert_eq!(profile.keywords.len(), 2);
}
#[test]
fn routing_decision_construction() {
let decision = RoutingDecision {
provider: "anthropic".into(),
model: "claude-opus-4-5".into(),
reason: "high complexity code task".into(),
..Default::default()
};
assert_eq!(decision.provider, "anthropic");
assert!(decision.tier.is_none());
assert!(decision.cost_estimate_usd.is_none());
assert!(!decision.escalated);
assert!(!decision.budget_constrained);
}
#[test]
fn quality_score_construction() {
let score = QualityScore {
overall: 0.9,
relevance: 0.95,
coherence: 0.85,
};
assert!(score.overall > 0.0 && score.overall <= 1.0);
}
#[test]
fn assembled_context_construction() {
let ctx = AssembledContext {
messages: vec![LlmMessage {
role: "system".into(),
content: "You are a helpful assistant.".into(),
tool_call_id: None,
tool_calls: None,
}],
token_estimate: 50,
truncated: false,
};
assert!(!ctx.truncated);
assert_eq!(ctx.token_estimate, 50);
}
#[test]
fn transport_request_construction() {
let req = TransportRequest {
provider: "openai".into(),
model: "gpt-4o".into(),
messages: vec![],
tools: vec![],
max_tokens: Some(2048),
temperature: None,
};
assert_eq!(req.provider, "openai");
assert!(req.temperature.is_none());
}
#[test]
fn response_outcome_construction() {
let score = QualityScore {
overall: 0.8,
relevance: 0.9,
coherence: 0.7,
};
let outcome = ResponseOutcome {
success: true,
quality: score,
latency_ms: 1500,
};
assert!(outcome.success);
assert_eq!(outcome.latency_ms, 1500);
}
#[test]
fn learning_signal_construction() {
let signal = LearningSignal {
feedback_type: "thumbs_up".into(),
value: 1.0,
};
assert_eq!(signal.feedback_type, "thumbs_up");
assert!((signal.value - 1.0).abs() < f32::EPSILON);
}
#[test]
fn chat_request_serde_roundtrip() {
let req = ChatRequest {
messages: vec![
LlmMessage {
role: "system".into(),
content: "You are helpful.".into(),
tool_call_id: None,
tool_calls: None,
},
LlmMessage {
role: "user".into(),
content: "Write a function".into(),
tool_call_id: None,
tool_calls: None,
},
],
tools: vec![serde_json::json!({"type": "function", "name": "web_search"})],
model: Some("gpt-4o".into()),
max_tokens: Some(4096),
temperature: Some(0.5),
auth_context: None,
complexity_boost: 0.0,
};
let json = serde_json::to_string(&req).unwrap();
let restored: ChatRequest = serde_json::from_str(&json).unwrap();
assert_eq!(restored.messages.len(), 2);
assert_eq!(restored.messages[0].role, "system");
assert_eq!(restored.model.as_deref(), Some("gpt-4o"));
assert_eq!(restored.max_tokens, Some(4096));
assert_eq!(restored.tools.len(), 1);
assert!(restored.auth_context.is_none());
}
#[test]
fn llm_message_serde_roundtrip() {
let msg = LlmMessage {
role: "tool".into(),
content: "search results".into(),
tool_call_id: Some("tc-42".into()),
tool_calls: None,
};
let json = serde_json::to_string(&msg).unwrap();
let restored: LlmMessage = serde_json::from_str(&json).unwrap();
assert_eq!(restored.role, "tool");
assert_eq!(restored.tool_call_id.as_deref(), Some("tc-42"));
}
#[test]
fn llm_message_serde_skips_none_tool_call_id() {
let msg = LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(!json.contains("tool_call_id"));
}
#[test]
fn task_type_serde_roundtrip() {
let types = [
TaskType::Chat,
TaskType::CodeGeneration,
TaskType::CodeReview,
TaskType::Research,
TaskType::Creative,
TaskType::Analysis,
TaskType::ToolUse,
TaskType::Unknown,
];
for tt in &types {
let json = serde_json::to_string(tt).unwrap();
let restored: TaskType = serde_json::from_str(&json).unwrap();
assert_eq!(&restored, tt);
}
}
#[test]
fn task_type_equality_and_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(TaskType::CodeGeneration);
set.insert(TaskType::CodeGeneration);
assert_eq!(set.len(), 1);
set.insert(TaskType::Chat);
assert_eq!(set.len(), 2);
}
#[test]
fn chat_request_with_defaults_deserializes() {
let json = r#"{"messages": [{"role": "user", "content": "hi"}]}"#;
let req: ChatRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.messages.len(), 1);
assert!(req.tools.is_empty());
assert!(req.model.is_none());
assert!(req.max_tokens.is_none());
assert!(req.temperature.is_none());
}
struct TestClassifier {
task_type: TaskType,
}
impl TaskClassifier for TestClassifier {
fn classify(&self, _request: &ChatRequest) -> TaskProfile {
TaskProfile {
task_type: self.task_type.clone(),
complexity: 0.5,
keywords: vec![],
}
}
}
struct TestRouter {
provider: String,
model: String,
}
#[async_trait]
impl ModelRouter for TestRouter {
async fn route(&self, _request: &ChatRequest, _profile: &TaskProfile) -> RoutingDecision {
RoutingDecision {
provider: self.provider.clone(),
model: self.model.clone(),
reason: "test".into(),
..Default::default()
}
}
fn update(&self, _decision: &RoutingDecision, _outcome: &ResponseOutcome) {}
}
struct TestAssembler;
#[async_trait]
impl ContextAssembler for TestAssembler {
async fn assemble(
&self,
request: &ChatRequest,
_profile: &TaskProfile,
) -> AssembledContext {
AssembledContext {
messages: request.messages.clone(),
token_estimate: 100,
truncated: false,
}
}
}
struct TestTransport;
#[async_trait]
impl LlmTransport for TestTransport {
async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
Ok(LlmResponse {
id: "test-resp".into(),
content: vec![ContentBlock::Text {
text: "Hello from test transport".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
struct TestScorer;
impl QualityScorer for TestScorer {
fn score(&self, _request: &ChatRequest, _response: &LlmResponse) -> QualityScore {
QualityScore {
overall: 1.0,
relevance: 1.0,
coherence: 1.0,
}
}
}
struct TestLearner;
impl LearningBackend for TestLearner {
fn record(&self, _trajectory: &Trajectory) {}
fn adapt(&self, _signal: &LearningSignal) {}
}
fn make_test_pipeline(task_type: TaskType, provider: &str, model: &str) -> Pipeline {
Pipeline {
classifier: Arc::new(TestClassifier { task_type }),
router: Arc::new(TestRouter {
provider: provider.into(),
model: model.into(),
}),
assembler: Arc::new(TestAssembler),
transport: Arc::new(TestTransport),
scorer: Arc::new(TestScorer),
learner: Arc::new(TestLearner),
}
}
#[test]
fn pipeline_registry_new() {
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
let pipeline = registry.get(&TaskType::CodeGeneration);
let _ = pipeline.classifier.classify(&ChatRequest {
messages: vec![],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
});
}
#[test]
fn pipeline_registry_register_and_get() {
let mut registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
registry.register(
TaskType::CodeGeneration,
make_test_pipeline(TaskType::CodeGeneration, "anthropic", "claude-opus-4-5"),
);
let _code_pipeline = registry.get(&TaskType::CodeGeneration);
let _default_pipeline = registry.get(&TaskType::Research);
}
#[tokio::test]
async fn pipeline_registry_complete_orchestrates_all_stages() {
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "test-resp");
assert_eq!(response.stop_reason, StopReason::EndTurn);
match &response.content[0] {
ContentBlock::Text { text } => {
assert_eq!(text, "Hello from test transport");
}
_ => panic!("expected text block"),
}
}
#[tokio::test]
async fn pipeline_registry_complete_uses_specialized_pipeline() {
let mut registry = PipelineRegistry::new(make_test_pipeline(
TaskType::CodeGeneration,
"default-provider",
"default-model",
));
registry.register(
TaskType::CodeGeneration,
make_test_pipeline(TaskType::CodeGeneration, "anthropic", "claude-opus-4-5"),
);
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "write code".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "test-resp");
}
#[test]
fn test_chat_request_skip_deserializing_auth_context() {
let json = r#"{
"messages": [{"role": "user", "content": "hi"}],
"auth_context": {
"sender_id": "injected",
"channel": "evil",
"permissions": {"level": 2}
}
}"#;
let req: ChatRequest = serde_json::from_str(json).unwrap();
assert!(
req.auth_context.is_none(),
"auth_context should be None after deserialization (skip_deserializing)"
);
}
#[test]
fn test_chat_request_without_auth_context_deserializes() {
let json = r#"{"messages": [{"role": "user", "content": "hi"}]}"#;
let req: ChatRequest = serde_json::from_str(json).unwrap();
assert!(req.auth_context.is_none());
assert_eq!(req.messages.len(), 1);
}
#[test]
fn test_chat_request_none_auth_context_serializes_as_null() {
let req = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hi".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
parsed.get("auth_context").is_some(),
"auth_context field should be present in serialized JSON"
);
assert!(
parsed["auth_context"].is_null(),
"auth_context: None should serialize as null, got: {}",
parsed["auth_context"]
);
}
#[test]
fn test_chat_request_with_auth_context_serializes_but_not_roundtrip() {
use clawft_types::routing::{AuthContext, UserPermissions};
let req = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hi".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: Some(AuthContext {
sender_id: "user_123".into(),
channel: "telegram".into(),
permissions: UserPermissions::default(),
}),
complexity_boost: 0.0,
};
let json = serde_json::to_string(&req).unwrap();
assert!(
json.contains("auth_context"),
"auth_context with Some value should appear in serialized JSON"
);
assert!(
json.contains("user_123"),
"sender_id should appear in serialized JSON"
);
let restored: ChatRequest = serde_json::from_str(&json).unwrap();
assert!(
restored.auth_context.is_none(),
"auth_context should be None after deserialization roundtrip"
);
}
#[tokio::test]
async fn test_pipeline_registry_passes_auth_context() {
use clawft_types::routing::AuthContext;
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: Some(AuthContext::cli_default()),
complexity_boost: 0.0,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "test-resp");
}
#[tokio::test]
async fn test_pipeline_registry_works_without_auth_context() {
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "test-resp");
}
}