Skip to main content

orchestral_runtime/planner/llm/
http.rs

1use async_trait::async_trait;
2use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
3use serde::{Deserialize, Serialize};
4
5use super::{LlmClient, LlmError, LlmRequest};
6
7#[derive(Debug, Clone)]
8pub struct HttpLlmClientConfig {
9    pub endpoint: String,
10    pub api_key: Option<String>,
11    pub model: String,
12    pub temperature: f32,
13    pub timeout_secs: u64,
14    pub extra_headers: HeaderMap,
15}
16
17impl Default for HttpLlmClientConfig {
18    fn default() -> Self {
19        Self {
20            endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
21            api_key: None,
22            model: "gpt-4o-mini".to_string(),
23            temperature: 0.2,
24            timeout_secs: 30,
25            extra_headers: HeaderMap::new(),
26        }
27    }
28}
29
30pub struct HttpLlmClient {
31    client: reqwest::Client,
32    config: HttpLlmClientConfig,
33}
34
35impl HttpLlmClient {
36    pub fn new(config: HttpLlmClientConfig) -> Result<Self, LlmError> {
37        let client = reqwest::Client::builder()
38            .timeout(std::time::Duration::from_secs(config.timeout_secs))
39            .build()
40            .map_err(|e| LlmError::Http(e.to_string()))?;
41        Ok(Self { client, config })
42    }
43}
44
45#[derive(Debug, Serialize)]
46struct ChatMessage {
47    role: String,
48    content: String,
49}
50
51#[derive(Debug, Serialize)]
52struct ChatRequest {
53    model: String,
54    messages: Vec<ChatMessage>,
55    temperature: f32,
56}
57
58#[derive(Debug, Deserialize)]
59struct ChatResponse {
60    choices: Vec<ChatChoice>,
61}
62
63#[derive(Debug, Deserialize)]
64struct ChatChoice {
65    message: ChatMessageResponse,
66}
67
68#[derive(Debug, Deserialize)]
69struct ChatMessageResponse {
70    content: String,
71}
72
73#[async_trait]
74impl LlmClient for HttpLlmClient {
75    async fn complete(&self, request: LlmRequest) -> Result<String, LlmError> {
76        let mut headers = self.config.extra_headers.clone();
77        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
78        if let Some(key) = &self.config.api_key {
79            let value = format!("Bearer {}", key);
80            headers.insert(
81                AUTHORIZATION,
82                HeaderValue::from_str(&value).map_err(|e| LlmError::Http(e.to_string()))?,
83            );
84        }
85
86        let body = ChatRequest {
87            model: request.model,
88            messages: vec![
89                ChatMessage {
90                    role: "system".to_string(),
91                    content: request.system,
92                },
93                ChatMessage {
94                    role: "user".to_string(),
95                    content: request.user,
96                },
97            ],
98            temperature: request.temperature,
99        };
100
101        let response = self
102            .client
103            .post(&self.config.endpoint)
104            .headers(headers)
105            .json(&body)
106            .send()
107            .await
108            .map_err(|e| LlmError::Http(e.to_string()))?;
109
110        if !response.status().is_success() {
111            let status = response.status();
112            let text = response.text().await.unwrap_or_default();
113            return Err(LlmError::Response(format!("HTTP {}: {}", status, text)));
114        }
115
116        let text = response
117            .text()
118            .await
119            .map_err(|e| LlmError::Http(e.to_string()))?;
120        let parsed: ChatResponse =
121            serde_json::from_str(&text).map_err(|e| LlmError::Serialization(e.to_string()))?;
122
123        let content = parsed
124            .choices
125            .first()
126            .map(|c| c.message.content.clone())
127            .ok_or_else(|| LlmError::Response("Missing choices".to_string()))?;
128
129        Ok(content)
130    }
131}