1use headless_lms_models::chatbot_configurations::{ReasoningEffortLevel, VerbosityLevel};
5use headless_lms_models::chatbot_conversation_message_messages::MessageRole;
6use headless_lms_utils::json_schema_types::{JSONType, Schema};
7use serde::{Deserialize, Deserializer, Serialize};
8use url::Url;
9
10use super::tools::AzureLLMToolDefinition;
11use crate::llm_utils::{APIInputMessage, MessageContent};
12use crate::prelude::*;
13
14#[derive(Deserialize, Serialize, Debug)]
16pub struct Response {
17 pub id: Option<String>,
18 pub error: Option<ResponseError>,
19 pub incomplete_details: Option<IncompleteReason>,
21 pub usage: Option<Usage>,
22 pub reasoning: Option<ResponseReasoning>,
23}
24
25#[derive(Deserialize, Serialize, Debug, Clone, Default)]
27pub struct ResponseReasoning {
28 pub context: Option<String>,
33}
34
35#[derive(Deserialize, Serialize, Debug, Clone, Default)]
38pub struct Usage {
39 pub input_tokens: Option<i64>,
40 pub output_tokens: Option<i64>,
41 pub total_tokens: Option<i64>,
42 pub input_tokens_details: Option<InputTokensDetails>,
43 pub output_tokens_details: Option<OutputTokensDetails>,
44}
45
46#[derive(Deserialize, Serialize, Debug, Clone, Default)]
49pub struct InputTokensDetails {
50 pub cached_tokens: Option<i64>,
51 pub cache_write_tokens: Option<i64>,
52}
53
54#[derive(Deserialize, Serialize, Debug, Clone, Default)]
57pub struct OutputTokensDetails {
58 pub reasoning_tokens: Option<i64>,
59}
60
61impl Usage {
62 pub fn log(&self, context: &str, reasoning: Option<&ResponseReasoning>) {
65 info!(
66 context,
67 input_tokens = self.input_tokens,
68 output_tokens = self.output_tokens,
69 reasoning_tokens = self
70 .output_tokens_details
71 .as_ref()
72 .and_then(|details| details.reasoning_tokens),
73 cached_tokens = self
74 .input_tokens_details
75 .as_ref()
76 .and_then(|details| details.cached_tokens),
77 cache_write_tokens = self
78 .input_tokens_details
79 .as_ref()
80 .and_then(|details| details.cache_write_tokens),
81 reasoning_context = reasoning.and_then(|reasoning| reasoning.context.as_deref()),
82 "LLM token usage"
83 );
84 }
85}
86
87#[derive(Deserialize, Serialize, Debug, Clone)]
90pub struct ResponseError {
91 pub code: Option<String>,
92 pub message: Option<String>,
93 #[serde(rename = "type")]
94 pub error_type: Option<String>,
95 pub param: Option<String>,
96}
97
98impl std::fmt::Display for ResponseError {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 write!(
101 f,
102 "{}: {} (code: {}, param: {})",
103 self.error_type.as_deref().unwrap_or("Error"),
104 self.message.as_deref().unwrap_or("unknown error"),
105 self.code.as_deref().unwrap_or("none"),
106 self.param.as_deref().unwrap_or("none")
107 )
108 }
109}
110
111#[derive(Deserialize, Serialize, Debug)]
113pub struct IncompleteReason {
114 pub reason: String,
115}
116
117#[derive(Deserialize, Serialize, Debug)]
120pub struct ResponseOutput {
121 #[serde(rename = "type")]
125 pub response_type: Option<String>, pub delta: Option<String>,
127 pub item: Option<ReceivedOutputItem>,
128 pub response: Option<Response>,
129 pub error: Option<ResponseError>,
130}
131
132#[derive(Deserialize, Serialize, Debug, Clone)]
138#[serde(untagged)]
139pub enum ReceivedOutputItem {
140 Known(OutputItem),
141 Unreadable(serde_json::Value),
142}
143
144impl ReceivedOutputItem {
145 pub fn known(self) -> Option<OutputItem> {
147 match self {
148 Self::Known(item) => Some(item),
149 Self::Unreadable(raw) => {
150 warn!("Ignoring an output item from Azure that could not be read: {raw}");
151 None
152 }
153 }
154 }
155}
156
157#[derive(Deserialize, Serialize, Debug, Clone)]
158#[serde(tag = "type")]
159#[serde(rename_all = "snake_case")]
160pub enum OutputItem {
161 Message {
162 response_id: String,
163 role: MessageRole,
164 content: MessageContent,
165 },
166 Reasoning {
167 response_id: String,
168 id: String,
169 summary: Vec<ReasoningOutput>,
170 #[serde(skip_serializing_if = "Option::is_none")]
173 encrypted_content: Option<String>,
174 },
175 AzureAiSearchCall {
176 response_id: String,
177 call_id: String,
178 arguments: String,
180 },
181 AzureAiSearchCallOutput {
182 response_id: String,
183 call_id: String,
184 output: String,
186 },
187 FunctionCall {
188 response_id: String,
189 call_id: String,
190 #[serde(rename = "name")]
191 tool_name: String,
192 arguments: String,
194 },
195 FunctionCallOutput {
196 response_id: String,
197 call_id: String,
198 output: String,
199 },
200}
201
202#[derive(Deserialize, Serialize, Debug, Clone)]
203#[serde(tag = "type")]
204#[serde(rename_all = "snake_case")]
205pub enum InputItem {
206 Message {
207 role: MessageRole,
208 content: MessageContent,
209 },
210 FunctionCall {
211 call_id: String,
212 #[serde(rename = "name")]
213 tool_name: String,
214 arguments: String,
215 },
216 FunctionCallOutput {
217 call_id: String,
218 output: String,
219 },
220 Reasoning {
221 id: String,
222 summary: Vec<ReasoningOutput>,
223 #[serde(skip_serializing_if = "Option::is_none")]
226 encrypted_content: Option<String>,
227 },
228}
229
230#[derive(Deserialize, Serialize, Debug, Clone)]
231pub struct AISearchOutput {
232 #[serde(deserialize_with = "urls_that_parse")]
233 pub get_urls: Vec<Url>,
234}
235
236fn urls_that_parse<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<Url>, D::Error> {
239 let raw = Vec::<String>::deserialize(deserializer)?;
240 Ok(raw
241 .into_iter()
242 .filter_map(|value| match Url::parse(&value) {
243 Ok(url) => Some(url),
244 Err(error) => {
245 warn!("Ignoring a cited document url the search returned: {value} ({error})");
246 None
247 }
248 })
249 .collect())
250}
251
252#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
253#[serde(rename_all = "snake_case")]
254pub enum LLMToolChoice {
255 Auto,
256 None,
257}
258
259#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
260#[serde(deny_unknown_fields)]
261pub struct ThinkingParams {
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub reasoning: Option<Reasoning>,
264}
265
266#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
267pub struct RequestTextOptions {
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub verbosity: Option<VerbosityLevel>,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub format: Option<LLMRequestResponseFormatParam>,
272}
273#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
274pub struct Reasoning {
275 pub effort: ReasoningEffortLevel,
276 pub summary: Option<SummaryType>,
277 #[serde(skip_serializing_if = "Option::is_none")]
280 pub context: Option<ReasoningContext>,
281}
282
283#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
285#[serde(rename_all = "snake_case")]
286pub enum ReasoningContext {
287 CurrentTurn,
291 AllTurns,
294}
295
296#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
298#[serde(rename_all = "snake_case")]
299pub enum SummaryType {
300 Concise,
301 Detailed,
302 Auto,
303}
304
305#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
306pub struct ReasoningOutput {
307 #[serde(rename = "type")]
308 pub output_type: String, pub text: String,
310}
311
312#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
313#[serde(deny_unknown_fields)]
314pub struct NonThinkingParams {
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub temperature: Option<f32>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub top_p: Option<f32>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub frequency_penalty: Option<f32>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub presence_penalty: Option<f32>,
323}
324
325#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
329#[serde(deny_unknown_fields)]
330pub struct MistralParams {
331 pub placeholder: bool,
332}
333
334#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
337#[serde(untagged)]
338pub enum LLMRequestParams {
339 GPTThinking(ThinkingParams),
340 GPTNonThinking(NonThinkingParams),
341 Mistral(MistralParams),
342}
343
344#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
345pub struct LLMRequestResponseFormatParam {
346 #[serde(rename = "type")]
347 pub format_type: JSONType, pub name: String,
349 pub schema: Schema,
350 pub strict: bool, }
352
353#[derive(Serialize, Deserialize, Debug, Clone)]
354pub struct LLMRequest {
355 pub input: Vec<APIInputMessage>,
356 pub model: String,
357 #[serde(skip_serializing_if = "Vec::is_empty", default)]
358 pub tools: Vec<AzureLLMToolDefinition>,
359 #[serde(skip_serializing_if = "Option::is_none")]
360 pub tool_choice: Option<LLMToolChoice>,
361 #[serde(skip_serializing_if = "Option::is_none")]
362 pub parallel_tool_calls: Option<bool>,
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub max_output_tokens: Option<i32>,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub text: Option<RequestTextOptions>,
367 #[serde(skip_serializing_if = "Option::is_none")]
370 pub prompt_cache_key: Option<String>,
371 #[serde(flatten)]
372 pub params: LLMRequestParams,
373}
374
375pub(super) fn azure_stream_error(response_id: Option<&str>, err: ResponseError) -> ChatbotError {
378 let mut error = chatbot_err!(
379 UpstreamReportedError,
380 format!(
381 "Error received from Azure API. Response id: {}",
382 response_id.unwrap_or("not received")
383 )
384 );
385 error.add_azure_source(err);
386 error
387}
388
389pub(crate) fn reported_azure_error(
393 output: &ResponseOutput,
394 fallback_response_id: Option<&str>,
395) -> Option<ChatbotError> {
396 if let Some(response) = &output.response
397 && let Some(err) = &response.error
398 {
399 return Some(azure_stream_error(response.id.as_deref(), err.clone()));
400 }
401 output
402 .error
403 .as_ref()
404 .map(|err| azure_stream_error(fallback_response_id, err.clone()))
405}
406
407pub(crate) fn reported_incomplete_reason(output: &ResponseOutput) -> Option<&str> {
410 output
411 .response
412 .as_ref()?
413 .incomplete_details
414 .as_ref()
415 .map(|details| details.reason.as_str())
416}
417
418pub(crate) fn log_response_usage(response_output: &ResponseOutput, context: &str) {
420 if let Some(response) = response_output.response.as_ref()
421 && let Some(usage) = response.usage.as_ref()
422 {
423 usage.log(context, response.reasoning.as_ref());
424 }
425}
426
427pub(crate) fn check_response_output(
430 output: &ResponseOutput,
431 response_id: Option<&str>,
432 context: &str,
433) -> ChatbotResult<()> {
434 log_response_usage(output, context);
435 match reported_azure_error(output, response_id) {
436 Some(error) => Err(error),
437 None => Ok(()),
438 }
439}
440
441pub(crate) fn check_response_complete(
444 output: &ResponseOutput,
445 incomplete_without_reason: bool,
446) -> ChatbotResult<()> {
447 let reason = reported_incomplete_reason(output)
448 .or_else(|| incomplete_without_reason.then_some("not reported"));
449 match reason {
450 Some(reason) => Err(chatbot_err!(
451 ResponseIncomplete,
452 format!("The LLM response is incomplete. Reason: {reason}")
453 )),
454 None => Ok(()),
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
465 fn response_failed_with_error_object_deserializes() {
466 let line = r#"{"type":"response.failed","response":{"id":"resp_abc","status":"failed","error":{"code":"tool_user_error","message":"Could not complete vectorization action."}},"sequence_number":8}"#;
467
468 let parsed: ResponseOutput = serde_json::from_str(line).unwrap();
469 let error = parsed
470 .response
471 .expect("response object")
472 .error
473 .expect("error object");
474
475 assert_eq!(error.code.as_deref(), Some("tool_user_error"));
476 assert!(error.message.unwrap().contains("vectorization"));
477 }
478
479 #[test]
481 fn azure_ai_search_output_items_deserialize() {
482 let call = r#"{"type":"azure_ai_search_call","id":"fc_1","response_id":"resp_abc","call_id":"call_1","arguments":"{\"query\":\"trademarks\"}","status":"completed"}"#;
483 match serde_json::from_str::<OutputItem>(call).unwrap() {
484 OutputItem::AzureAiSearchCall { arguments, .. } => {
485 assert!(arguments.contains("trademarks"))
486 }
487 other => panic!("expected AzureAiSearchCall, got {other:?}"),
488 }
489
490 let output = r#"{"type":"azure_ai_search_call_output","id":"fco_1","response_id":"resp_abc","call_id":"call_1","output":"remote tool call failed","status":"in_progress"}"#;
491 match serde_json::from_str::<OutputItem>(output).unwrap() {
492 OutputItem::AzureAiSearchCallOutput { output, .. } => {
493 assert_eq!(output, "remote tool call failed")
494 }
495 other => panic!("expected AzureAiSearchCallOutput, got {other:?}"),
496 }
497 }
498
499 #[test]
502 fn an_unknown_item_type_deserializes_as_unreadable_instead_of_failing() {
503 let line = r#"{"type":"response.output_item.done","item":{"type":"web_search_call","id":"ws_1","status":"completed"}}"#;
504
505 let parsed: ResponseOutput =
506 serde_json::from_str(line).expect("the line deserializes despite the unknown item");
507 assert!(matches!(
508 parsed.item,
509 Some(ReceivedOutputItem::Unreadable(_))
510 ));
511 assert!(parsed.item.and_then(ReceivedOutputItem::known).is_none());
512 }
513}