Skip to main content

ringkernel_ecosystem/
llm.rs

1//! LLM Provider Bridge — FR-012
2//!
3//! Integration layer for calling LLMs from within the GPU actor system.
4//! Each provider implements the `LlmProvider` trait. Requests and responses
5//! flow as K2K messages, making LLM calls a natural actor interaction.
6//!
7//! # Supported Providers
8//!
9//! - **OpenAI**: GPT-4, GPT-3.5, embeddings
10//! - **Anthropic**: Claude (messages API, tool use)
11//! - **Local**: Ollama, vLLM, llama.cpp via HTTP
12//!
13//! # Architecture
14//!
15//! ```text
16//! GPU Actor → K2K "LLM Request" → LLM Bridge Actor → HTTP → Provider
17//!                                                          ↓
18//! GPU Actor ← K2K "LLM Response" ← LLM Bridge Actor ← HTTP ← Provider
19//! ```
20
21use std::collections::HashMap;
22use std::time::Duration;
23
24/// An LLM completion request.
25#[derive(Debug, Clone)]
26pub struct CompletionRequest {
27    /// Model identifier (e.g., "gpt-4", "claude-3-opus", "llama-3-70b").
28    pub model: String,
29    /// Messages in the conversation.
30    pub messages: Vec<ChatMessage>,
31    /// Maximum tokens to generate.
32    pub max_tokens: Option<u32>,
33    /// Temperature (0.0 = deterministic, 1.0 = creative).
34    pub temperature: Option<f32>,
35    /// System prompt.
36    pub system: Option<String>,
37    /// Tool/function definitions (for function calling).
38    pub tools: Vec<ToolDefinition>,
39    /// Stop sequences.
40    pub stop: Vec<String>,
41    /// Request metadata (for tracking, routing).
42    pub metadata: HashMap<String, String>,
43}
44
45impl Default for CompletionRequest {
46    fn default() -> Self {
47        Self {
48            model: "gpt-4".to_string(),
49            messages: Vec::new(),
50            max_tokens: Some(1024),
51            temperature: Some(0.7),
52            system: None,
53            tools: Vec::new(),
54            stop: Vec::new(),
55            metadata: HashMap::new(),
56        }
57    }
58}
59
60impl CompletionRequest {
61    /// Create a simple completion request.
62    pub fn new(model: impl Into<String>, user_message: impl Into<String>) -> Self {
63        Self {
64            model: model.into(),
65            messages: vec![ChatMessage::user(user_message)],
66            ..Default::default()
67        }
68    }
69
70    /// Add a system prompt.
71    pub fn with_system(mut self, system: impl Into<String>) -> Self {
72        self.system = Some(system.into());
73        self
74    }
75
76    /// Set max tokens.
77    pub fn with_max_tokens(mut self, max: u32) -> Self {
78        self.max_tokens = Some(max);
79        self
80    }
81
82    /// Set temperature.
83    pub fn with_temperature(mut self, temp: f32) -> Self {
84        self.temperature = Some(temp);
85        self
86    }
87
88    /// Add a tool definition.
89    pub fn with_tool(mut self, tool: ToolDefinition) -> Self {
90        self.tools.push(tool);
91        self
92    }
93}
94
95/// A chat message.
96#[derive(Debug, Clone)]
97pub struct ChatMessage {
98    /// Role: "system", "user", "assistant", "tool".
99    pub role: String,
100    /// Message content.
101    pub content: String,
102    /// Tool call ID (for tool responses).
103    pub tool_call_id: Option<String>,
104    /// Tool calls made by the assistant.
105    pub tool_calls: Vec<ToolCall>,
106}
107
108impl ChatMessage {
109    /// Create a user message.
110    pub fn user(content: impl Into<String>) -> Self {
111        Self {
112            role: "user".to_string(),
113            content: content.into(),
114            tool_call_id: None,
115            tool_calls: Vec::new(),
116        }
117    }
118
119    /// Create an assistant message.
120    pub fn assistant(content: impl Into<String>) -> Self {
121        Self {
122            role: "assistant".to_string(),
123            content: content.into(),
124            tool_call_id: None,
125            tool_calls: Vec::new(),
126        }
127    }
128
129    /// Create a tool response message.
130    pub fn tool_response(call_id: impl Into<String>, content: impl Into<String>) -> Self {
131        Self {
132            role: "tool".to_string(),
133            content: content.into(),
134            tool_call_id: Some(call_id.into()),
135            tool_calls: Vec::new(),
136        }
137    }
138}
139
140/// A tool/function definition for LLM function calling.
141#[derive(Debug, Clone)]
142pub struct ToolDefinition {
143    /// Tool name.
144    pub name: String,
145    /// Tool description.
146    pub description: String,
147    /// Parameter schema (JSON Schema as string).
148    pub parameters_schema: String,
149}
150
151/// A tool call made by the LLM.
152#[derive(Debug, Clone)]
153pub struct ToolCall {
154    /// Unique call ID.
155    pub id: String,
156    /// Tool name.
157    pub name: String,
158    /// Arguments (JSON string).
159    pub arguments: String,
160}
161
162/// An LLM completion response.
163#[derive(Debug, Clone)]
164pub struct CompletionResponse {
165    /// Generated text content.
166    pub content: String,
167    /// Model used.
168    pub model: String,
169    /// Tool calls (if function calling was used).
170    pub tool_calls: Vec<ToolCall>,
171    /// Finish reason.
172    pub finish_reason: FinishReason,
173    /// Token usage.
174    pub usage: TokenUsage,
175    /// Response latency.
176    pub latency: Duration,
177    /// Provider-specific response ID.
178    pub response_id: Option<String>,
179}
180
181/// Why the LLM stopped generating.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum FinishReason {
184    /// Reached natural end.
185    Stop,
186    /// Hit max_tokens limit.
187    MaxTokens,
188    /// Used a tool/function call.
189    ToolUse,
190    /// Content was filtered.
191    ContentFilter,
192    /// Unknown reason.
193    Unknown,
194}
195
196/// Token usage tracking.
197#[derive(Debug, Clone, Copy, Default)]
198pub struct TokenUsage {
199    /// Prompt/input tokens.
200    pub prompt_tokens: u32,
201    /// Completion/output tokens.
202    pub completion_tokens: u32,
203    /// Total tokens.
204    pub total_tokens: u32,
205}
206
207impl TokenUsage {
208    /// Estimated cost in USD (rough, provider-dependent).
209    pub fn estimated_cost(&self, prompt_cost_per_1k: f64, completion_cost_per_1k: f64) -> f64 {
210        (self.prompt_tokens as f64 / 1000.0 * prompt_cost_per_1k)
211            + (self.completion_tokens as f64 / 1000.0 * completion_cost_per_1k)
212    }
213}
214
215/// An embedding request.
216#[derive(Debug, Clone)]
217pub struct EmbeddingRequest {
218    /// Model for embeddings (e.g., "text-embedding-3-small").
219    pub model: String,
220    /// Texts to embed.
221    pub inputs: Vec<String>,
222    /// Dimensionality (if model supports it).
223    pub dimensions: Option<u32>,
224}
225
226/// An embedding response.
227#[derive(Debug, Clone)]
228pub struct EmbeddingResponse {
229    /// Embedding vectors (one per input).
230    pub embeddings: Vec<Vec<f32>>,
231    /// Token usage.
232    pub usage: TokenUsage,
233    /// Response latency.
234    pub latency: Duration,
235}
236
237/// Trait for LLM provider implementations.
238pub trait LlmProvider: Send + Sync {
239    /// Provider name (e.g., "openai", "anthropic", "ollama").
240    fn name(&self) -> &str;
241
242    /// Send a completion request.
243    fn complete(&self, request: &CompletionRequest) -> Result<CompletionResponse, LlmError>;
244
245    /// Generate embeddings.
246    fn embed(&self, request: &EmbeddingRequest) -> Result<EmbeddingResponse, LlmError>;
247
248    /// Check if the provider is available/connected.
249    fn is_available(&self) -> bool;
250
251    /// Get provider metrics.
252    fn metrics(&self) -> LlmProviderMetrics;
253}
254
255/// LLM provider metrics.
256#[derive(Debug, Clone, Default)]
257pub struct LlmProviderMetrics {
258    /// Total requests made.
259    pub total_requests: u64,
260    /// Total errors.
261    pub total_errors: u64,
262    /// Total tokens consumed.
263    pub total_tokens: u64,
264    /// Total estimated cost (USD).
265    pub total_cost_usd: f64,
266    /// Average latency.
267    pub avg_latency: Duration,
268    /// Rate limit remaining.
269    pub rate_limit_remaining: Option<u32>,
270}
271
272/// LLM errors.
273#[derive(Debug, Clone)]
274pub enum LlmError {
275    /// Provider not available.
276    Unavailable(String),
277    /// Rate limited.
278    RateLimited {
279        /// Duration to wait before retrying.
280        retry_after: Duration,
281    },
282    /// Authentication failed.
283    AuthError(String),
284    /// Request invalid.
285    InvalidRequest(String),
286    /// Model not found.
287    ModelNotFound(String),
288    /// Content filtered.
289    ContentFiltered,
290    /// Timeout.
291    Timeout(Duration),
292    /// Provider error.
293    ProviderError(String),
294}
295
296impl std::fmt::Display for LlmError {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self {
299            Self::Unavailable(msg) => write!(f, "Provider unavailable: {}", msg),
300            Self::RateLimited { retry_after } => {
301                write!(f, "Rate limited, retry after {:?}", retry_after)
302            }
303            Self::AuthError(msg) => write!(f, "Auth error: {}", msg),
304            Self::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
305            Self::ModelNotFound(model) => write!(f, "Model not found: {}", model),
306            Self::ContentFiltered => write!(f, "Content filtered"),
307            Self::Timeout(d) => write!(f, "Timeout after {:?}", d),
308            Self::ProviderError(msg) => write!(f, "Provider error: {}", msg),
309        }
310    }
311}
312
313impl std::error::Error for LlmError {}
314
315// ============================================================================
316// Echo Provider (for testing)
317// ============================================================================
318
319/// Test LLM provider that echoes the input. Useful for testing the bridge.
320pub struct EchoProvider {
321    total_requests: std::sync::atomic::AtomicU64,
322}
323
324impl EchoProvider {
325    /// Create a new echo provider.
326    pub fn new() -> Self {
327        Self {
328            total_requests: std::sync::atomic::AtomicU64::new(0),
329        }
330    }
331}
332
333impl Default for EchoProvider {
334    fn default() -> Self {
335        Self::new()
336    }
337}
338
339impl LlmProvider for EchoProvider {
340    fn name(&self) -> &str {
341        "echo"
342    }
343
344    fn complete(&self, request: &CompletionRequest) -> Result<CompletionResponse, LlmError> {
345        self.total_requests
346            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
347
348        let content = request
349            .messages
350            .last()
351            .map(|m| format!("Echo: {}", m.content))
352            .unwrap_or_else(|| "Echo: (empty)".to_string());
353
354        Ok(CompletionResponse {
355            content,
356            model: request.model.clone(),
357            tool_calls: Vec::new(),
358            finish_reason: FinishReason::Stop,
359            usage: TokenUsage {
360                prompt_tokens: 10,
361                completion_tokens: 10,
362                total_tokens: 20,
363            },
364            latency: Duration::from_micros(100),
365            response_id: Some("echo-001".to_string()),
366        })
367    }
368
369    fn embed(&self, request: &EmbeddingRequest) -> Result<EmbeddingResponse, LlmError> {
370        let dim = request.dimensions.unwrap_or(384) as usize;
371        let embeddings: Vec<Vec<f32>> = request
372            .inputs
373            .iter()
374            .map(|input| {
375                // Deterministic pseudo-embedding based on input hash
376                let hash = input
377                    .bytes()
378                    .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
379                (0..dim)
380                    .map(|i| ((hash.wrapping_add(i as u64) % 1000) as f32 / 1000.0) - 0.5)
381                    .collect()
382            })
383            .collect();
384
385        Ok(EmbeddingResponse {
386            embeddings,
387            usage: TokenUsage {
388                prompt_tokens: request.inputs.len() as u32 * 5,
389                completion_tokens: 0,
390                total_tokens: request.inputs.len() as u32 * 5,
391            },
392            latency: Duration::from_micros(50),
393        })
394    }
395
396    fn is_available(&self) -> bool {
397        true
398    }
399
400    fn metrics(&self) -> LlmProviderMetrics {
401        LlmProviderMetrics {
402            total_requests: self
403                .total_requests
404                .load(std::sync::atomic::Ordering::Relaxed),
405            ..Default::default()
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_echo_provider_complete() {
416        let provider = EchoProvider::new();
417        let request = CompletionRequest::new("test-model", "Hello, world!");
418        let response = provider.complete(&request).unwrap();
419
420        assert!(response.content.contains("Hello, world!"));
421        assert_eq!(response.finish_reason, FinishReason::Stop);
422        assert_eq!(response.usage.total_tokens, 20);
423    }
424
425    #[test]
426    fn test_echo_provider_embed() {
427        let provider = EchoProvider::new();
428        let request = EmbeddingRequest {
429            model: "test-embed".to_string(),
430            inputs: vec!["hello".to_string(), "world".to_string()],
431            dimensions: Some(128),
432        };
433        let response = provider.embed(&request).unwrap();
434
435        assert_eq!(response.embeddings.len(), 2);
436        assert_eq!(response.embeddings[0].len(), 128);
437    }
438
439    #[test]
440    fn test_completion_request_builder() {
441        let req = CompletionRequest::new("gpt-4", "Analyze this audit finding")
442            .with_system("You are an ISA compliance expert")
443            .with_max_tokens(2048)
444            .with_temperature(0.0);
445
446        assert_eq!(req.model, "gpt-4");
447        assert_eq!(req.system.unwrap(), "You are an ISA compliance expert");
448        assert_eq!(req.max_tokens, Some(2048));
449        assert_eq!(req.temperature, Some(0.0));
450    }
451
452    #[test]
453    fn test_chat_message_types() {
454        let user = ChatMessage::user("question");
455        assert_eq!(user.role, "user");
456
457        let assistant = ChatMessage::assistant("answer");
458        assert_eq!(assistant.role, "assistant");
459
460        let tool = ChatMessage::tool_response("call-123", "result");
461        assert_eq!(tool.role, "tool");
462        assert_eq!(tool.tool_call_id.unwrap(), "call-123");
463    }
464
465    #[test]
466    fn test_token_cost_estimation() {
467        let usage = TokenUsage {
468            prompt_tokens: 1000,
469            completion_tokens: 500,
470            total_tokens: 1500,
471        };
472        // GPT-4 pricing: ~$0.03/1K prompt, ~$0.06/1K completion
473        let cost = usage.estimated_cost(0.03, 0.06);
474        assert!((cost - 0.06).abs() < 0.001); // $0.03 + $0.03 = $0.06
475    }
476
477    #[test]
478    fn test_provider_metrics() {
479        let provider = EchoProvider::new();
480
481        provider
482            .complete(&CompletionRequest::new("m", "a"))
483            .unwrap();
484        provider
485            .complete(&CompletionRequest::new("m", "b"))
486            .unwrap();
487
488        let m = provider.metrics();
489        assert_eq!(m.total_requests, 2);
490    }
491
492    #[test]
493    fn test_tool_definition() {
494        let tool = ToolDefinition {
495            name: "graph_query".to_string(),
496            description: "Query the knowledge graph".to_string(),
497            parameters_schema: r#"{"type":"object","properties":{"query":{"type":"string"}}}"#
498                .to_string(),
499        };
500        assert_eq!(tool.name, "graph_query");
501    }
502}