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