Skip to main content

cortexai_fastly/
lib.rs

1//! # Rust AI Agents - Fastly Compute
2//!
3//! Run AI agents on Fastly Compute@Edge.
4//!
5//! This crate provides a Fastly-native implementation using:
6//! - `cortexai-llm-client` for request/response logic
7//! - Fastly SDK for HTTP requests
8//!
9//! ## Usage
10//!
11//! ```rust,ignore
12//! use cortexai_fastly::{FastlyAgent, FastlyAgentConfig};
13//! use fastly::{Request, Response};
14//!
15//! #[fastly::main]
16//! fn main(req: Request) -> Result<Response, fastly::Error> {
17//!     let config = FastlyAgentConfig::new(
18//!         "openai",
19//!         std::env::var("OPENAI_API_KEY").unwrap(),
20//!         "gpt-4o-mini",
21//!     );
22//!
23//!     let mut agent = FastlyAgent::new(config, "llm-backend");
24//!     let response = agent.chat("Hello!")?;
25//!
26//!     Ok(Response::from_body(response.content))
27//! }
28//! ```
29
30use std::io::Read;
31
32use fastly::http::{header, Method, StatusCode};
33use fastly::{Backend, Body, Request, Response};
34use cortexai_llm_client::{
35    LlmResponse, Message, Provider, RequestBuilder, ResponseParser, StreamChunk,
36};
37use serde::{Deserialize, Serialize};
38use thiserror::Error;
39
40/// Errors that can occur in the Fastly agent
41#[derive(Debug, Error)]
42pub enum FastlyAgentError {
43    #[error("LLM client error: {0}")]
44    LlmClient(#[from] cortexai_llm_client::LlmClientError),
45
46    #[error("Fastly error: {0}")]
47    Fastly(String),
48
49    #[error("HTTP error: {status} - {message}")]
50    Http { status: u16, message: String },
51
52    #[error("JSON error: {0}")]
53    Json(#[from] serde_json::Error),
54
55    #[error("Invalid provider: {0}")]
56    InvalidProvider(String),
57}
58
59impl From<fastly::Error> for FastlyAgentError {
60    fn from(e: fastly::Error) -> Self {
61        FastlyAgentError::Fastly(e.to_string())
62    }
63}
64
65impl From<fastly::http::request::SendError> for FastlyAgentError {
66    fn from(e: fastly::http::request::SendError) -> Self {
67        FastlyAgentError::Fastly(format!("Send error: {:?}", e))
68    }
69}
70
71pub type Result<T> = std::result::Result<T, FastlyAgentError>;
72
73/// Configuration for the Fastly agent
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct FastlyAgentConfig {
76    /// LLM provider
77    pub provider: String,
78    /// API key
79    pub api_key: String,
80    /// Model identifier
81    pub model: String,
82    /// System prompt
83    pub system_prompt: Option<String>,
84    /// Temperature (0.0 - 2.0)
85    pub temperature: f32,
86    /// Maximum tokens to generate
87    pub max_tokens: u32,
88}
89
90impl FastlyAgentConfig {
91    /// Create a new configuration
92    pub fn new(
93        provider: impl Into<String>,
94        api_key: impl Into<String>,
95        model: impl Into<String>,
96    ) -> Self {
97        Self {
98            provider: provider.into(),
99            api_key: api_key.into(),
100            model: model.into(),
101            system_prompt: None,
102            temperature: 0.7,
103            max_tokens: 4096,
104        }
105    }
106
107    /// Set system prompt
108    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
109        self.system_prompt = Some(prompt.into());
110        self
111    }
112
113    /// Set temperature
114    pub fn with_temperature(mut self, temp: f32) -> Self {
115        self.temperature = temp.clamp(0.0, 2.0);
116        self
117    }
118
119    /// Set max tokens
120    pub fn with_max_tokens(mut self, tokens: u32) -> Self {
121        self.max_tokens = tokens;
122        self
123    }
124}
125
126/// AI Agent for Fastly Compute
127pub struct FastlyAgent {
128    config: FastlyAgentConfig,
129    backend_name: String,
130    messages: Vec<Message>,
131    provider: Provider,
132}
133
134impl FastlyAgent {
135    /// Create a new Fastly agent
136    ///
137    /// # Arguments
138    /// * `config` - Agent configuration
139    /// * `backend_name` - Name of the Fastly backend configured for LLM API
140    pub fn new(config: FastlyAgentConfig, backend_name: impl Into<String>) -> Result<Self> {
141        let provider = config
142            .provider
143            .parse::<Provider>()
144            .map_err(|_| FastlyAgentError::InvalidProvider(config.provider.clone()))?;
145
146        Ok(Self {
147            config,
148            backend_name: backend_name.into(),
149            messages: Vec::new(),
150            provider,
151        })
152    }
153
154    /// Send a message and get a response
155    pub fn chat(&mut self, message: &str) -> Result<LlmResponse> {
156        // Add user message
157        self.messages.push(Message::user(message));
158
159        // Build request using llm-client
160        let mut builder = RequestBuilder::new(self.provider)
161            .model(&self.config.model)
162            .api_key(&self.config.api_key)
163            .temperature(self.config.temperature)
164            .max_tokens(self.config.max_tokens)
165            .stream(false);
166
167        // Add system prompt if present
168        if let Some(ref system) = self.config.system_prompt {
169            builder = builder.add_message(Message::system(system));
170        }
171
172        // Add conversation messages
173        builder = builder.messages(&self.messages);
174
175        let http_request = builder.build()?;
176
177        // Create Fastly request
178        let mut req = Request::new(Method::POST, &http_request.url);
179
180        for (key, value) in &http_request.headers {
181            req.set_header(key, value);
182        }
183
184        req.set_body(http_request.body);
185
186        // Send request via Fastly backend
187        let backend = Backend::from_name(&self.backend_name)
188            .map_err(|e| FastlyAgentError::Fastly(format!("Backend not found: {}", e)))?;
189
190        let resp = req.send(backend)?;
191
192        // Check status
193        if resp.get_status() != StatusCode::OK {
194            let status = resp.get_status().as_u16();
195            let body = resp.into_body_str();
196            return Err(FastlyAgentError::Http {
197                status,
198                message: body,
199            });
200        }
201
202        // Parse response using llm-client
203        let body = resp.into_body_str();
204        let response = ResponseParser::parse(self.provider, &body)?;
205
206        // Add assistant message to history
207        self.messages.push(Message::assistant(&response.content));
208
209        Ok(response)
210    }
211
212    /// Send a message and get a streaming response
213    ///
214    /// Returns an iterator over stream chunks
215    pub fn chat_stream(&mut self, message: &str) -> Result<StreamingResponse> {
216        // Add user message
217        self.messages.push(Message::user(message));
218
219        // Build request using llm-client
220        let mut builder = RequestBuilder::new(self.provider)
221            .model(&self.config.model)
222            .api_key(&self.config.api_key)
223            .temperature(self.config.temperature)
224            .max_tokens(self.config.max_tokens)
225            .stream(true);
226
227        // Add system prompt if present
228        if let Some(ref system) = self.config.system_prompt {
229            builder = builder.add_message(Message::system(system));
230        }
231
232        // Add conversation messages
233        builder = builder.messages(&self.messages);
234
235        let http_request = builder.build()?;
236
237        // Create Fastly request
238        let mut req = Request::new(Method::POST, &http_request.url);
239
240        for (key, value) in &http_request.headers {
241            req.set_header(key, value);
242        }
243
244        req.set_body(http_request.body);
245
246        // Send request via Fastly backend
247        let backend = Backend::from_name(&self.backend_name)
248            .map_err(|e| FastlyAgentError::Fastly(format!("Backend not found: {}", e)))?;
249
250        let resp = req.send(backend)?;
251
252        // Check status
253        if resp.get_status() != StatusCode::OK {
254            let status = resp.get_status().as_u16();
255            let body = resp.into_body_str();
256            return Err(FastlyAgentError::Http {
257                status,
258                message: body,
259            });
260        }
261
262        Ok(StreamingResponse {
263            body: resp.into_body(),
264            provider: self.provider,
265            buffer: String::new(),
266            accumulated_content: String::new(),
267        })
268    }
269
270    /// Clear conversation history
271    pub fn clear(&mut self) {
272        self.messages.clear();
273    }
274
275    /// Get conversation history
276    pub fn history(&self) -> &[Message] {
277        &self.messages
278    }
279
280    /// Add assistant response to history (for streaming)
281    pub fn add_assistant_message(&mut self, content: impl Into<String>) {
282        self.messages.push(Message::assistant(content));
283    }
284}
285
286/// Streaming response iterator
287pub struct StreamingResponse {
288    body: Body,
289    provider: Provider,
290    buffer: String,
291    accumulated_content: String,
292}
293
294impl StreamingResponse {
295    /// Get the full accumulated content after streaming completes
296    pub fn content(&self) -> &str {
297        &self.accumulated_content
298    }
299
300    /// Process the next chunk from the stream
301    pub fn next_chunk(&mut self) -> Result<Option<StreamChunk>> {
302        // Read more data into buffer
303        let mut chunk_buf = [0u8; 1024];
304        let bytes_read = self
305            .body
306            .read(&mut chunk_buf)
307            .map_err(|e| FastlyAgentError::Fastly(e.to_string()))?;
308
309        if bytes_read == 0 {
310            return Ok(None);
311        }
312
313        // Add to buffer
314        let chunk_str = String::from_utf8_lossy(&chunk_buf[..bytes_read]);
315        self.buffer.push_str(&chunk_str);
316
317        // Process complete lines
318        while let Some(line_end) = self.buffer.find('\n') {
319            let line = self.buffer[..line_end].to_string();
320            self.buffer = self.buffer[line_end + 1..].to_string();
321
322            if let Ok(Some(chunk)) = ResponseParser::parse_stream_line(self.provider, &line) {
323                if let Some(ref content) = chunk.content {
324                    self.accumulated_content.push_str(content);
325                }
326                return Ok(Some(chunk));
327            }
328        }
329
330        // No complete chunk yet, keep reading
331        self.next_chunk()
332    }
333}
334
335impl std::io::Read for StreamingResponse {
336    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
337        self.body.read(buf)
338    }
339}
340
341/// Create a simple chat completion handler for Fastly
342///
343/// This is a convenience function for simple use cases.
344pub fn handle_chat_request(
345    incoming_req: Request,
346    config: FastlyAgentConfig,
347    backend_name: &str,
348) -> Result<Response> {
349    // Parse incoming request
350    let body: serde_json::Value = serde_json::from_str(&incoming_req.into_body_str())?;
351
352    let message = body["message"]
353        .as_str()
354        .ok_or_else(|| FastlyAgentError::Fastly("Missing 'message' field".to_string()))?;
355
356    // Create agent and chat
357    let mut agent = FastlyAgent::new(config, backend_name)?;
358    let response = agent.chat(message)?;
359
360    // Build response
361    let response_body = serde_json::json!({
362        "content": response.content,
363        "usage": response.usage,
364    });
365
366    let mut resp = Response::from_body(response_body.to_string());
367    resp.set_header(header::CONTENT_TYPE, "application/json");
368
369    Ok(resp)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_config_creation() {
378        let config = FastlyAgentConfig::new("openai", "sk-test", "gpt-4o-mini")
379            .with_system_prompt("You are helpful")
380            .with_temperature(0.5)
381            .with_max_tokens(2048);
382
383        assert_eq!(config.provider, "openai");
384        assert_eq!(config.model, "gpt-4o-mini");
385        assert_eq!(config.system_prompt, Some("You are helpful".to_string()));
386        assert_eq!(config.temperature, 0.5);
387        assert_eq!(config.max_tokens, 2048);
388    }
389
390    #[test]
391    fn test_provider_parsing() {
392        assert!("openai".parse::<Provider>().is_ok());
393        assert!("anthropic".parse::<Provider>().is_ok());
394        assert!("openrouter".parse::<Provider>().is_ok());
395        assert!("invalid".parse::<Provider>().is_err());
396    }
397}