Skip to main content

everruns_core/
utility_llm.rs

1//! System utility LLM service.
2//!
3//! This is a host-owned service for capability internals, not an agent-visible
4//! model provider. It is configured once per deployment and deliberately keeps
5//! the model fixed so call sites cannot turn it into a user-selectable model.
6
7use crate::{
8    AgentLoopError, LlmCallConfig, LlmDriver, LlmMessage, LlmResponse, LlmResponseStream,
9    OpenResponsesProtocolLlmDriver, Result,
10};
11use async_trait::async_trait;
12use std::collections::HashMap;
13use std::sync::Arc;
14
15pub const UTILITY_LLM_MODEL: &str = "gpt-5.5";
16pub const UTILITY_OPENAI_API_KEY_ENV: &str = "UTILITY_OPENAI_API_KEY";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum UtilityLlmReasoningEffort {
20    Low,
21    Medium,
22    High,
23}
24
25impl UtilityLlmReasoningEffort {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::Low => "low",
29            Self::Medium => "medium",
30            Self::High => "high",
31        }
32    }
33}
34
35#[derive(Debug, Clone)]
36pub struct UtilityLlmRequest {
37    pub messages: Vec<LlmMessage>,
38    pub reasoning_effort: Option<UtilityLlmReasoningEffort>,
39    pub temperature: Option<f32>,
40    pub max_tokens: Option<u32>,
41    pub metadata: HashMap<String, String>,
42}
43
44impl UtilityLlmRequest {
45    pub fn new(messages: Vec<LlmMessage>) -> Self {
46        Self {
47            messages,
48            reasoning_effort: None,
49            temperature: None,
50            max_tokens: None,
51            metadata: HashMap::new(),
52        }
53    }
54
55    pub fn user_text(prompt: impl Into<String>) -> Self {
56        Self::new(vec![LlmMessage::text(
57            crate::LlmMessageRole::User,
58            prompt.into(),
59        )])
60    }
61
62    pub fn with_reasoning_effort(mut self, effort: UtilityLlmReasoningEffort) -> Self {
63        self.reasoning_effort = Some(effort);
64        self
65    }
66
67    pub fn with_temperature(mut self, temperature: f32) -> Self {
68        self.temperature = Some(temperature);
69        self
70    }
71
72    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
73        self.max_tokens = Some(max_tokens);
74        self
75    }
76
77    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
78        self.metadata.insert(key.into(), value.into());
79        self
80    }
81
82    fn into_parts(self) -> Result<(Vec<LlmMessage>, LlmCallConfig)> {
83        if self.messages.is_empty() {
84            return Err(AgentLoopError::llm(
85                "utility LLM request must include at least one message",
86            ));
87        }
88
89        let config = LlmCallConfig {
90            model: UTILITY_LLM_MODEL.to_string(),
91            temperature: self.temperature,
92            max_tokens: self.max_tokens,
93            tools: Vec::new(),
94            reasoning_effort: self
95                .reasoning_effort
96                .map(|effort| effort.as_str().to_string()),
97            metadata: self.metadata,
98            previous_response_id: None,
99            tool_search: None,
100            prompt_cache: None,
101        };
102        Ok((self.messages, config))
103    }
104}
105
106#[async_trait]
107pub trait UtilityLlmService: Send + Sync {
108    fn is_configured(&self) -> bool;
109
110    async fn chat_completion(&self, request: UtilityLlmRequest) -> Result<LlmResponse>;
111
112    async fn chat_completion_stream(&self, request: UtilityLlmRequest)
113    -> Result<LlmResponseStream>;
114
115    fn name(&self) -> &'static str {
116        "UtilityLlmService"
117    }
118}
119
120#[derive(Debug, Clone, Default)]
121pub struct DisabledUtilityLlmService;
122
123#[async_trait]
124impl UtilityLlmService for DisabledUtilityLlmService {
125    fn is_configured(&self) -> bool {
126        false
127    }
128
129    async fn chat_completion(&self, _request: UtilityLlmRequest) -> Result<LlmResponse> {
130        Err(AgentLoopError::llm("utility LLM service is disabled"))
131    }
132
133    async fn chat_completion_stream(
134        &self,
135        _request: UtilityLlmRequest,
136    ) -> Result<LlmResponseStream> {
137        Err(AgentLoopError::llm("utility LLM service is disabled"))
138    }
139
140    fn name(&self) -> &'static str {
141        "DisabledUtilityLlmService"
142    }
143}
144
145#[derive(Clone)]
146pub struct OpenAiUtilityLlmService {
147    driver: OpenResponsesProtocolLlmDriver,
148}
149
150impl std::fmt::Debug for OpenAiUtilityLlmService {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.debug_struct("OpenAiUtilityLlmService")
153            .field("model", &UTILITY_LLM_MODEL)
154            .field("configured", &true)
155            .finish()
156    }
157}
158
159impl OpenAiUtilityLlmService {
160    pub fn new(api_key: impl Into<String>) -> Self {
161        // THREAT[TM-LLM-021]: Utility LLM credentials must not become agent- or
162        // session-configurable. Keep the key inside this host service.
163        Self {
164            driver: OpenResponsesProtocolLlmDriver::new(api_key),
165        }
166    }
167}
168
169#[async_trait]
170impl UtilityLlmService for OpenAiUtilityLlmService {
171    fn is_configured(&self) -> bool {
172        true
173    }
174
175    async fn chat_completion(&self, request: UtilityLlmRequest) -> Result<LlmResponse> {
176        let (messages, config) = request.into_parts()?;
177        self.driver.chat_completion(messages, &config).await
178    }
179
180    async fn chat_completion_stream(
181        &self,
182        request: UtilityLlmRequest,
183    ) -> Result<LlmResponseStream> {
184        let (messages, config) = request.into_parts()?;
185        self.driver.chat_completion_stream(messages, &config).await
186    }
187
188    fn name(&self) -> &'static str {
189        "OpenAiUtilityLlmService"
190    }
191}
192
193#[derive(Clone, PartialEq, Eq)]
194pub enum SystemUtilityLlmConfig {
195    Disabled,
196    OpenAi { api_key: String },
197}
198
199impl std::fmt::Debug for SystemUtilityLlmConfig {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            Self::Disabled => f.debug_struct("SystemUtilityLlmConfig::Disabled").finish(),
203            Self::OpenAi { .. } => f
204                .debug_struct("SystemUtilityLlmConfig::OpenAi")
205                .field("api_key", &"<redacted>")
206                .finish(),
207        }
208    }
209}
210
211impl SystemUtilityLlmConfig {
212    pub fn from_env() -> Self {
213        match env_opt(UTILITY_OPENAI_API_KEY_ENV) {
214            Some(api_key) => Self::OpenAi { api_key },
215            None => Self::Disabled,
216        }
217    }
218
219    pub fn into_service(self) -> Arc<dyn UtilityLlmService> {
220        match self {
221            Self::Disabled => Arc::new(DisabledUtilityLlmService),
222            Self::OpenAi { api_key } => Arc::new(OpenAiUtilityLlmService::new(api_key)),
223        }
224    }
225}
226
227fn env_opt(name: &str) -> Option<String> {
228    std::env::var(name).ok().filter(|value| !value.is_empty())
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::LlmMessageRole;
235
236    #[tokio::test]
237    async fn disabled_service_reports_not_configured() {
238        let service = DisabledUtilityLlmService;
239
240        assert!(!service.is_configured());
241        let error = service
242            .chat_completion(UtilityLlmRequest::user_text("summarize this"))
243            .await
244            .unwrap_err();
245        assert!(error.to_string().contains("disabled"));
246    }
247
248    #[test]
249    fn request_builds_hardcoded_model_without_reasoning_by_default() {
250        let request = UtilityLlmRequest::user_text("summarize this");
251        let (messages, config) = request.into_parts().unwrap();
252
253        assert_eq!(messages.len(), 1);
254        assert_eq!(config.model, UTILITY_LLM_MODEL);
255        assert_eq!(config.reasoning_effort, None);
256        assert!(config.tools.is_empty());
257        assert!(config.tool_search.is_none());
258    }
259
260    #[test]
261    fn request_accepts_supported_reasoning_efforts() {
262        for (effort, expected) in [
263            (UtilityLlmReasoningEffort::Low, "low"),
264            (UtilityLlmReasoningEffort::Medium, "medium"),
265            (UtilityLlmReasoningEffort::High, "high"),
266        ] {
267            let (_, config) = UtilityLlmRequest::new(vec![LlmMessage::text(
268                LlmMessageRole::User,
269                "classify this",
270            )])
271            .with_reasoning_effort(effort)
272            .into_parts()
273            .unwrap();
274
275            assert_eq!(config.reasoning_effort.as_deref(), Some(expected));
276        }
277    }
278
279    #[test]
280    fn request_requires_messages() {
281        let error = UtilityLlmRequest::new(vec![]).into_parts().unwrap_err();
282
283        assert!(error.to_string().contains("at least one message"));
284    }
285
286    #[test]
287    fn system_config_debug_redacts_api_key() {
288        let debug = format!(
289            "{:?}",
290            SystemUtilityLlmConfig::OpenAi {
291                api_key: "sk-secret-value".to_string(),
292            }
293        );
294
295        assert!(debug.contains("<redacted>"));
296        assert!(!debug.contains("sk-secret-value"));
297    }
298}