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