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, ChatDriver, LlmCallConfig, LlmMessage, LlmResponse, LlmResponseStream,
9    OpenResponsesProtocolChatDriver, 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            speed: None,
91            verbosity: None,
92            model: UTILITY_LLM_MODEL.to_string(),
93            temperature: self.temperature,
94            max_tokens: self.max_tokens,
95            tools: Vec::new(),
96            reasoning_effort: self
97                .reasoning_effort
98                .map(|effort| effort.as_str().to_string()),
99            metadata: self.metadata,
100            previous_response_id: None,
101            tool_search: None,
102            prompt_cache: None,
103            openrouter_routing: None,
104            parallel_tool_calls: None,
105            volatile_suffix_len: 0,
106        };
107        Ok((self.messages, config))
108    }
109}
110
111#[async_trait]
112pub trait UtilityLlmService: Send + Sync {
113    fn is_configured(&self) -> bool;
114
115    async fn chat_completion(&self, request: UtilityLlmRequest) -> Result<LlmResponse>;
116
117    async fn chat_completion_stream(&self, request: UtilityLlmRequest)
118    -> Result<LlmResponseStream>;
119
120    fn name(&self) -> &'static str {
121        "UtilityLlmService"
122    }
123}
124
125#[derive(Debug, Clone, Default)]
126pub struct DisabledUtilityLlmService;
127
128#[async_trait]
129impl UtilityLlmService for DisabledUtilityLlmService {
130    fn is_configured(&self) -> bool {
131        false
132    }
133
134    async fn chat_completion(&self, _request: UtilityLlmRequest) -> Result<LlmResponse> {
135        Err(AgentLoopError::llm("utility LLM service is disabled"))
136    }
137
138    async fn chat_completion_stream(
139        &self,
140        _request: UtilityLlmRequest,
141    ) -> Result<LlmResponseStream> {
142        Err(AgentLoopError::llm("utility LLM service is disabled"))
143    }
144
145    fn name(&self) -> &'static str {
146        "DisabledUtilityLlmService"
147    }
148}
149
150#[derive(Clone)]
151pub struct OpenAiUtilityLlmService {
152    driver: OpenResponsesProtocolChatDriver,
153}
154
155impl std::fmt::Debug for OpenAiUtilityLlmService {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.debug_struct("OpenAiUtilityLlmService")
158            .field("model", &UTILITY_LLM_MODEL)
159            .field("configured", &true)
160            .finish()
161    }
162}
163
164impl OpenAiUtilityLlmService {
165    pub fn new(api_key: impl Into<String>) -> Self {
166        // THREAT[TM-LLM-021]: Utility LLM credentials must not become agent- or
167        // session-configurable. Keep the key inside this host service.
168        Self {
169            driver: OpenResponsesProtocolChatDriver::new(api_key),
170        }
171    }
172}
173
174#[async_trait]
175impl UtilityLlmService for OpenAiUtilityLlmService {
176    fn is_configured(&self) -> bool {
177        true
178    }
179
180    async fn chat_completion(&self, request: UtilityLlmRequest) -> Result<LlmResponse> {
181        let (messages, config) = request.into_parts()?;
182        self.driver.chat_completion(messages, &config).await
183    }
184
185    async fn chat_completion_stream(
186        &self,
187        request: UtilityLlmRequest,
188    ) -> Result<LlmResponseStream> {
189        let (messages, config) = request.into_parts()?;
190        self.driver.chat_completion_stream(messages, &config).await
191    }
192
193    fn name(&self) -> &'static str {
194        "OpenAiUtilityLlmService"
195    }
196}
197
198#[derive(Clone, PartialEq, Eq)]
199pub enum SystemUtilityLlmConfig {
200    Disabled,
201    OpenAi { api_key: String },
202}
203
204impl std::fmt::Debug for SystemUtilityLlmConfig {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        match self {
207            Self::Disabled => f.debug_struct("SystemUtilityLlmConfig::Disabled").finish(),
208            Self::OpenAi { .. } => f
209                .debug_struct("SystemUtilityLlmConfig::OpenAi")
210                .field("api_key", &"<redacted>")
211                .finish(),
212        }
213    }
214}
215
216impl SystemUtilityLlmConfig {
217    pub fn from_env() -> Self {
218        match env_opt(UTILITY_OPENAI_API_KEY_ENV) {
219            Some(api_key) => Self::OpenAi { api_key },
220            None => Self::Disabled,
221        }
222    }
223
224    pub fn into_service(self) -> Arc<dyn UtilityLlmService> {
225        match self {
226            Self::Disabled => Arc::new(DisabledUtilityLlmService),
227            Self::OpenAi { api_key } => Arc::new(OpenAiUtilityLlmService::new(api_key)),
228        }
229    }
230}
231
232fn env_opt(name: &str) -> Option<String> {
233    std::env::var(name).ok().filter(|value| !value.is_empty())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::LlmMessageRole;
240
241    #[tokio::test]
242    async fn disabled_service_reports_not_configured() {
243        let service = DisabledUtilityLlmService;
244
245        assert!(!service.is_configured());
246        let error = service
247            .chat_completion(UtilityLlmRequest::user_text("summarize this"))
248            .await
249            .unwrap_err();
250        assert!(error.to_string().contains("disabled"));
251    }
252
253    #[test]
254    fn request_builds_hardcoded_model_without_reasoning_by_default() {
255        let request = UtilityLlmRequest::user_text("summarize this");
256        let (messages, config) = request.into_parts().unwrap();
257
258        assert_eq!(messages.len(), 1);
259        assert_eq!(config.model, UTILITY_LLM_MODEL);
260        assert_eq!(config.reasoning_effort, None);
261        assert!(config.tools.is_empty());
262        assert!(config.tool_search.is_none());
263    }
264
265    #[test]
266    fn request_accepts_supported_reasoning_efforts() {
267        for (effort, expected) in [
268            (UtilityLlmReasoningEffort::Low, "low"),
269            (UtilityLlmReasoningEffort::Medium, "medium"),
270            (UtilityLlmReasoningEffort::High, "high"),
271        ] {
272            let (_, config) = UtilityLlmRequest::new(vec![LlmMessage::text(
273                LlmMessageRole::User,
274                "classify this",
275            )])
276            .with_reasoning_effort(effort)
277            .into_parts()
278            .unwrap();
279
280            assert_eq!(config.reasoning_effort.as_deref(), Some(expected));
281        }
282    }
283
284    #[test]
285    fn request_requires_messages() {
286        let error = UtilityLlmRequest::new(vec![]).into_parts().unwrap_err();
287
288        assert!(error.to_string().contains("at least one message"));
289    }
290
291    #[test]
292    fn system_config_debug_redacts_api_key() {
293        let debug = format!(
294            "{:?}",
295            SystemUtilityLlmConfig::OpenAi {
296                api_key: "sk-secret-value".to_string(),
297            }
298        );
299
300        assert!(debug.contains("<redacted>"));
301        assert!(!debug.contains("sk-secret-value"));
302    }
303}