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