1use 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 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}