1use secrecy::{ExposeSecret, SecretString};
2
3use crate::{
4 azure_chatbot::azure::protocol::{
5 InputItem, LLMRequest, LLMRequestParams, LLMRequestResponseFormatParam, MistralParams,
6 NonThinkingParams, OutputItem, Reasoning, ReasoningContext, ReasoningOutput,
7 RequestTextOptions, Response as AzureResponse, ResponseError, ResponseReasoning,
8 SummaryType, ThinkingParams, Usage,
9 },
10 azure_chatbot::azure::tools::AZURE_AI_SEARCH_TOOL_NAME,
11 chatbot_error::ChatbotResult,
12 chatbot_tools::tool_is_answered_by_client,
13 prelude::*,
14};
15use core::default::Default;
16use headless_lms_base::config::{
17 ApplicationConfiguration, AzureChatbotConfiguration, AzureConfiguration,
18 AzureSearchConfiguration,
19};
20use headless_lms_models::{
21 chatbot_configurations::{ChatbotConfiguration, ReasoningEffortLevel},
22 chatbot_configurations_models::ModelType,
23 chatbot_conversation_message_messages::{ChatbotConversationMessageMessage, MessageRole},
24 chatbot_conversation_message_reasoning::ChatbotConversationMessageReasoning,
25 chatbot_conversation_message_tool_calls::{ChatbotConversationMessageToolCall, ToolKind},
26 chatbot_conversation_message_tool_outputs::ChatbotConversationMessageToolOutput,
27 chatbot_conversation_messages::{ChatbotConversationMessage, Message},
28};
29use headless_lms_utils::json_schema_types::{Schema, string_array_property};
30use indexmap::IndexMap;
31use reqwest::Response;
32use reqwest::header::HeaderMap;
33use serde::{Deserialize, Serialize};
34use tracing::{debug, error, instrument, trace, warn};
35
36pub fn azure_configuration(
38 app_config: &ApplicationConfiguration,
39) -> ChatbotResult<&AzureConfiguration> {
40 app_config.azure_configuration.as_ref().ok_or_else(|| {
41 chatbot_err!(
42 AzureRequestBuildError,
43 "Azure configuration is missing from the application configuration"
44 )
45 })
46}
47
48pub fn azure_search_configuration(
50 app_config: &ApplicationConfiguration,
51) -> ChatbotResult<&AzureSearchConfiguration> {
52 azure_configuration(app_config)?
53 .search_config
54 .as_ref()
55 .ok_or_else(|| {
56 chatbot_err!(
57 AzureRequestBuildError,
58 "Search configuration is missing from the Azure configuration"
59 )
60 })
61}
62
63pub fn azure_chatbot_configuration(
66 app_config: &ApplicationConfiguration,
67) -> ChatbotResult<&AzureChatbotConfiguration> {
68 azure_configuration(app_config)?
69 .chatbot_config
70 .as_ref()
71 .ok_or_else(|| {
72 chatbot_err!(
73 AzureRequestBuildError,
74 "Chatbot configuration is missing from the Azure configuration"
75 )
76 })
77}
78
79#[derive(Serialize, Deserialize, Debug, Clone)]
81pub struct APIOutputMessage {
82 #[serde(flatten)]
83 pub message_type: OutputItem,
84}
85
86#[derive(Serialize, Deserialize, Debug, Clone)]
88pub struct APIInputMessage {
89 #[serde(flatten)]
90 pub message_type: InputItem,
91}
92
93fn summary_text(parts: &[ReasoningOutput]) -> Option<String> {
96 if parts.is_empty() {
97 return None;
98 }
99 Some(
100 parts
101 .iter()
102 .map(|part| part.text.as_str())
103 .collect::<Vec<_>>()
104 .join(" "),
105 )
106}
107
108fn stored_summary(text: Option<String>) -> Vec<ReasoningOutput> {
110 text.into_iter()
111 .map(|text| ReasoningOutput {
112 output_type: "summary_text".to_string(),
113 text,
114 })
115 .collect()
116}
117
118fn summary_as_stored(parts: &[ReasoningOutput]) -> Vec<ReasoningOutput> {
125 stored_summary(summary_text(parts))
126}
127
128impl From<APIOutputMessage> for APIInputMessage {
129 fn from(message: APIOutputMessage) -> Self {
130 match message.message_type {
131 OutputItem::Message { role, content, .. } => APIInputMessage {
134 message_type: InputItem::Message {
135 role,
136 content: MessageContent::Text(content.get_content_text()),
137 },
138 },
139 OutputItem::FunctionCall {
140 call_id,
141 tool_name,
142 arguments,
143 ..
144 } => APIInputMessage {
145 message_type: InputItem::FunctionCall {
146 call_id,
147 tool_name,
148 arguments,
149 },
150 },
151 OutputItem::FunctionCallOutput {
152 call_id, output, ..
153 } => APIInputMessage {
154 message_type: InputItem::FunctionCallOutput { call_id, output },
155 },
156 OutputItem::AzureAiSearchCall {
157 call_id, arguments, ..
158 } => APIInputMessage {
159 message_type: InputItem::FunctionCall {
160 call_id,
161 tool_name: AZURE_AI_SEARCH_TOOL_NAME.to_string(),
162 arguments,
163 },
164 },
165 OutputItem::AzureAiSearchCallOutput {
166 call_id, output, ..
167 } => APIInputMessage {
168 message_type: InputItem::FunctionCallOutput { call_id, output },
169 },
170 OutputItem::Reasoning {
171 id,
172 summary,
173 encrypted_content,
174 ..
175 } => APIInputMessage {
176 message_type: InputItem::Reasoning {
177 id,
178 summary: summary_as_stored(&summary),
179 encrypted_content,
180 },
181 },
182 }
183 }
184}
185
186impl TryFrom<ChatbotConversationMessage> for APIInputMessage {
187 type Error = ChatbotError;
188
189 fn try_from(message: ChatbotConversationMessage) -> Result<Self, Self::Error> {
190 let res = match message.message {
191 Message::Text(text_message) => match text_message.message_role {
192 MessageRole::User | MessageRole::Assistant | MessageRole::Developer => {
193 APIInputMessage {
194 message_type: InputItem::Message {
195 role: text_message.message_role,
196 content: MessageContent::Text(text_message.text),
197 },
198 }
199 }
200 MessageRole::System => {
201 return Err(chatbot_err!(
202 InvalidMessageShape,
203 "A 'role: system' type text-variant ChatbotConversationMessage shouldn't be saved into the database."
204 ));
205 }
206 },
207 Message::ToolCall(tool_call) => APIInputMessage {
208 message_type: InputItem::FunctionCall {
209 arguments: tool_call.arguments_json(),
210 call_id: tool_call.tool_call_id,
211 tool_name: if tool_call.tool_kind.is_provider_tool() {
212 AZURE_AI_SEARCH_TOOL_NAME.to_string()
213 } else {
214 tool_call.tool_name
215 },
216 },
217 },
218 Message::ToolOutput(tool_output) => APIInputMessage {
219 message_type: InputItem::FunctionCallOutput {
220 call_id: tool_output.tool_call_id,
221 output: tool_output.output,
222 },
223 },
224 Message::Reasoning(ChatbotConversationMessageReasoning {
225 reasoning_id,
226 summary,
227 encrypted_content,
228 ..
229 }) => APIInputMessage {
230 message_type: InputItem::Reasoning {
231 id: reasoning_id,
232 summary: stored_summary(summary),
233 encrypted_content,
234 },
235 },
236 };
237 Result::Ok(res)
238 }
239}
240
241#[derive(Serialize, Deserialize, Debug, Clone)]
242#[serde(untagged)]
243pub enum MessageContent {
244 Text(String),
245 OutputText(Vec<MessageContentItem>),
246 Refusal(Vec<RefusalContentItem>),
247}
248
249#[derive(Serialize, Deserialize, Debug, Clone)]
250pub struct MessageContentItem {
251 pub text: String,
252}
253
254#[derive(Serialize, Deserialize, Debug, Clone)]
255pub struct RefusalContentItem {
256 pub refusal: String,
257}
258
259impl MessageContent {
260 pub fn get_content_text(self) -> String {
261 match self {
262 MessageContent::Text(msg_text) => msg_text,
263 MessageContent::OutputText(output) => output
264 .iter()
265 .map(|x| x.text.to_owned())
266 .collect::<Vec<String>>()
267 .join(""),
268 MessageContent::Refusal(refusal) => refusal
269 .iter()
270 .map(|x| x.refusal.to_owned())
271 .collect::<Vec<String>>()
272 .join(""),
273 }
274 }
275}
276
277impl APIOutputMessage {
278 pub fn to_chatbot_conversation_message(
283 &self,
284 conversation_id: Uuid,
285 ) -> ChatbotResult<ChatbotConversationMessage> {
286 let res = match self.message_type.clone() {
287 OutputItem::Message {
288 role,
289 content,
290 response_id,
291 ..
292 } => {
293 let text = content.get_content_text();
294 let used_tokens = estimate_tokens(&text);
295
296 ChatbotConversationMessage {
297 conversation_id,
298 message: Message::Text(ChatbotConversationMessageMessage {
299 text,
300 message_role: role,
301 message_is_complete: true,
302 used_tokens,
303 response_id: if role == MessageRole::User {
304 None
305 } else {
306 Some(response_id)
307 },
308 ..Default::default()
309 }),
310 ..Default::default()
311 }
312 }
313 OutputItem::FunctionCall {
314 call_id,
315 tool_name,
316 arguments,
317 response_id,
318 } => {
319 let tool_kind = if tool_is_answered_by_client(&tool_name) {
322 ToolKind::ClientTool
323 } else {
324 ToolKind::Function
325 };
326 ChatbotConversationMessage {
327 conversation_id,
328 message: Message::ToolCall(ChatbotConversationMessageToolCall::new(
329 call_id,
330 tool_name,
331 arguments,
332 tool_kind,
333 response_id,
334 )),
335 ..Default::default()
336 }
337 }
338 OutputItem::FunctionCallOutput {
339 call_id,
340 output,
341 response_id,
342 } => ChatbotConversationMessage {
343 conversation_id,
344 message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
345 output,
346 tool_call_id: call_id,
347 tool_kind: ToolKind::Function,
348 response_id,
349 ..Default::default()
350 }),
351 ..Default::default()
352 },
353 OutputItem::AzureAiSearchCall {
354 call_id,
355 arguments,
356 response_id,
357 } => ChatbotConversationMessage {
358 conversation_id,
359 message: Message::ToolCall(ChatbotConversationMessageToolCall::new(
360 call_id,
361 AZURE_AI_SEARCH_TOOL_NAME.to_string(),
362 arguments,
363 ToolKind::AzureAiSearch,
364 response_id,
365 )),
366 ..Default::default()
367 },
368 OutputItem::AzureAiSearchCallOutput {
369 call_id,
370 output,
371 response_id,
372 } => ChatbotConversationMessage {
373 conversation_id,
374 message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
375 tool_call_id: call_id,
376 tool_kind: ToolKind::AzureAiSearch,
377 output,
378 response_id,
379 ..Default::default()
380 }),
381 ..Default::default()
382 },
383 OutputItem::Reasoning {
384 summary,
385 response_id,
386 id,
387 encrypted_content,
388 } => ChatbotConversationMessage {
389 conversation_id,
390 message: Message::Reasoning(ChatbotConversationMessageReasoning {
391 summary: summary_text(&summary),
392 response_id,
393 reasoning_id: id,
394 encrypted_content,
395 ..Default::default()
396 }),
397 ..Default::default()
398 },
399 };
400 Result::Ok(res)
401 }
402}
403
404impl TryFrom<ChatbotConversationMessage> for APIOutputMessage {
405 type Error = ChatbotError;
406
407 fn try_from(message: ChatbotConversationMessage) -> ChatbotResult<Self> {
408 let res = match message.message {
409 Message::Text(text_message) => match text_message.message_role {
410 MessageRole::User | MessageRole::Assistant | MessageRole::Developer => {
411 APIOutputMessage {
412 message_type: OutputItem::Message {
413 role: text_message.message_role,
414 content: MessageContent::Text(text_message.text),
415 response_id: if text_message.message_role == MessageRole::User {
416 "".to_string()
417 } else {
418 text_message.response_id.ok_or(chatbot_err!(
419 Other,
420 "Can't convert ChatbotConversationMessage into APIOutputMessage: only a role='user' message may lack a response_id"
421 ))?
422 },
423 },
424 }
425 }
426 MessageRole::System => {
427 return Err(chatbot_err!(
428 InvalidMessageShape,
429 "A 'role: system' type text-variant ChatbotConversationMessage shouldn't be saved into the database."
430 ));
431 }
432 },
433 Message::ToolCall(tool_call) => {
434 let arguments = tool_call.arguments_json();
435 if tool_call.tool_kind.is_provider_tool() {
436 APIOutputMessage {
437 message_type: OutputItem::AzureAiSearchCall {
438 call_id: tool_call.tool_call_id,
439 arguments,
440 response_id: tool_call.response_id,
441 },
442 }
443 } else {
444 APIOutputMessage {
445 message_type: OutputItem::FunctionCall {
446 call_id: tool_call.tool_call_id,
447 tool_name: tool_call.tool_name,
448 arguments,
449 response_id: tool_call.response_id,
450 },
451 }
452 }
453 }
454 Message::ToolOutput(tool_output) => APIOutputMessage::from(tool_output),
455 Message::Reasoning(reasoning) => APIOutputMessage {
456 message_type: OutputItem::Reasoning {
457 summary: stored_summary(reasoning.summary),
458 response_id: reasoning.response_id,
459 id: reasoning.reasoning_id,
460 encrypted_content: reasoning.encrypted_content,
461 },
462 },
463 };
464 Result::Ok(res)
465 }
466}
467
468impl From<ChatbotConversationMessageToolOutput> for APIOutputMessage {
469 fn from(value: ChatbotConversationMessageToolOutput) -> Self {
470 if value.tool_kind.is_provider_tool() {
471 APIOutputMessage {
472 message_type: OutputItem::AzureAiSearchCallOutput {
473 response_id: value.response_id,
474 call_id: value.tool_call_id,
475 output: value.output,
476 },
477 }
478 } else {
479 APIOutputMessage {
480 message_type: OutputItem::FunctionCallOutput {
481 call_id: value.tool_call_id,
482 output: value.output,
483 response_id: value.response_id,
484 },
485 }
486 }
487 }
488}
489
490impl TryFrom<APIOutputMessage> for ChatbotConversationMessageToolOutput {
491 type Error = ChatbotError;
492 fn try_from(value: APIOutputMessage) -> ChatbotResult<Self> {
493 match value.message_type {
494 OutputItem::FunctionCallOutput {
495 call_id,
496 output,
497 response_id,
498 } => Ok(ChatbotConversationMessageToolOutput {
499 output,
500 tool_call_id: call_id,
501 response_id,
502 ..Default::default()
503 }),
504 OutputItem::AzureAiSearchCallOutput {
505 response_id,
506 call_id,
507 output,
508 } => Ok(ChatbotConversationMessageToolOutput {
509 output,
510 tool_call_id: call_id,
511 response_id,
512 ..Default::default()
513 }),
514 _ => Err(chatbot_err!(
515 Other,
516 "Can't convert APIMessage to ChatbotConversationMessageToolOutput: APIMessage type is not OutputItem::FunctionCallOutput"
517 )),
518 }
519 }
520}
521
522#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
523pub struct APITool {
524 pub arguments: String,
525 pub name: String,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq)]
535pub struct StoreDisabled;
536
537impl Serialize for StoreDisabled {
538 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
539 serializer.serialize_bool(false)
540 }
541}
542
543impl<'de> Deserialize<'de> for StoreDisabled {
544 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
545 if bool::deserialize(deserializer)? {
546 return Err(serde::de::Error::custom(
547 "a request that asks Azure to store the response is not one this service sends",
548 ));
549 }
550 Ok(StoreDisabled)
551 }
552}
553
554fn reasoning_include(params: &LLMRequestParams) -> Option<Vec<String>> {
562 match params {
563 LLMRequestParams::GPTThinking(_) => Some(vec!["reasoning.encrypted_content".to_string()]),
564 LLMRequestParams::GPTNonThinking(_) | LLMRequestParams::Mistral(_) => None,
565 }
566}
567
568#[derive(Serialize, Deserialize, Debug)]
571pub struct AzureCompletionRequest {
572 #[serde(flatten)]
573 pub base: LLMRequest,
574 pub stream: bool,
575 pub store: StoreDisabled,
576 #[serde(skip_serializing_if = "Option::is_none")]
578 pub include: Option<Vec<String>>,
579}
580
581#[derive(Serialize)]
588struct AzureCompletionRequestRef<'a> {
589 #[serde(flatten)]
590 base: &'a LLMRequest,
591 stream: bool,
592 store: StoreDisabled,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 include: Option<Vec<String>>,
595}
596
597#[derive(Deserialize, Debug)]
599pub struct LLMResponse {
600 pub id: String,
601 pub output: Vec<APIOutputMessage>,
602 pub usage: Option<Usage>,
603 pub reasoning: Option<ResponseReasoning>,
604}
605
606pub fn string_list_response_format(name: &str, property: &str) -> LLMRequestResponseFormatParam {
611 LLMRequestResponseFormatParam::JsonSchema {
612 name: name.to_string(),
613 schema: Schema::strict_object(
614 IndexMap::from([(property.to_string(), string_array_property(None))]),
615 None,
616 ),
617 strict: true,
618 }
619}
620
621#[instrument(skip(api_key), fields(api_key_length = api_key.expose_secret().len()))]
623pub fn build_llm_headers(api_key: &SecretString) -> ChatbotResult<HeaderMap> {
624 trace!("Building LLM request headers");
625 let mut headers = HeaderMap::new();
626 headers.insert(
627 "api-key",
628 api_key.expose_secret().parse().map_err(|_e| {
630 error!("Failed to parse API key");
631 chatbot_err!(AzureRequestBuildError, "Invalid API key")
632 })?,
633 );
634 headers.insert(
635 "content-type",
636 "application/json".parse().map_err(|_e| {
637 error!("Failed to parse content-type header");
638 chatbot_err!(AzureRequestBuildError, "Internal error")
639 })?,
640 );
641 trace!("Successfully built headers");
642 Ok(headers)
643}
644
645pub fn azure_search_request(
648 method: reqwest::Method,
649 url: url::Url,
650 search_config: &AzureSearchConfiguration,
651) -> reqwest::RequestBuilder {
652 REQWEST_CLIENT
653 .request(method, url)
654 .header("Content-Type", "application/json")
655 .header("api-key", search_config.search_api_key.expose_secret())
656}
657
658pub(crate) fn summarize_input_for_log(input: &[APIInputMessage]) -> String {
666 input
667 .iter()
668 .map(|message| match &message.message_type {
669 InputItem::Message { role, .. } => format!("Message({role:?})"),
670 InputItem::FunctionCall {
671 call_id, tool_name, ..
672 } => format!("FunctionCall({tool_name}, {call_id})"),
673 InputItem::FunctionCallOutput { call_id, .. } => {
674 format!("FunctionCallOutput({call_id})")
675 }
676 InputItem::Reasoning {
677 id,
678 encrypted_content,
679 ..
680 } => format!(
681 "Reasoning({id}, encrypted_content={})",
682 if encrypted_content.is_some() {
683 "present"
684 } else {
685 "absent"
686 }
687 ),
688 })
689 .collect::<Vec<_>>()
690 .join(" -> ")
691}
692
693#[instrument(skip(text), fields(text_length = text.len()))]
695pub fn estimate_tokens(text: &str) -> i32 {
696 trace!("Estimating tokens for text");
697 let text_length = text.chars().fold(0, |acc, c| {
698 let mut len = c.len_utf8() as i32;
699 if len > 1 {
700 len *= 2;
702 }
703 if c.is_ascii_punctuation() {
704 len *= 2;
706 }
707 acc + len
708 });
709 let estimated_tokens = text_length / 4;
711 trace!("Estimated {} tokens for text", estimated_tokens);
712 estimated_tokens
713}
714
715#[instrument(skip(chat_request, endpoint, api_key), fields(
717 num_messages = chat_request.input.len(),
718 temperature,
719 max_tokens,
720 endpoint = %endpoint
721))]
722async fn make_llm_request(
723 chat_request: LLMRequest,
724 endpoint: &url::Url,
725 api_key: &SecretString,
726) -> ChatbotResult<LLMResponse> {
727 debug!(
728 "Preparing LLM request with {} messages",
729 chat_request.input.len()
730 );
731
732 trace!("Base request: {:?}", chat_request);
733
734 let request = AzureCompletionRequest {
735 include: reasoning_include(&chat_request.params),
736 base: chat_request,
737 stream: false,
738 store: StoreDisabled,
739 };
740
741 let headers = build_llm_headers(api_key)?;
742 debug!("Sending request to LLM endpoint: {}", endpoint);
743
744 let response = REQWEST_CLIENT
745 .post(endpoint.clone())
746 .headers(headers)
747 .json(&request)
748 .send()
749 .await?;
750
751 trace!("Received response from LLM");
752 process_llm_response(response, &request.base.input).await
753}
754
755fn llm_http_error(status: reqwest::StatusCode, error_text: String) -> ChatbotError {
758 let azure_response = serde_json::from_str::<AzureResponse>(&error_text);
759 match azure_response {
760 Ok(response) => {
761 let azure_error: Option<ResponseError> = response.error;
762 let mut error = chatbot_err!(
764 FailedAzureResponse,
765 format!(
766 "Error calling LLM API: Status: {}. Error: {}",
767 status,
768 &azure_error
769 .as_ref()
770 .and_then(|e| e.code.to_owned())
771 .or_else(|| azure_error.as_ref().and_then(|e| e.error_type.to_owned()))
772 .unwrap_or(error_text)
773 )
774 );
775 if let Some(e) = azure_error {
776 error.add_azure_source(e);
777 };
778 error
779 }
780 Err(_) => chatbot_err!(
782 FailedAzureResponse,
783 format!(
784 "Error calling LLM API: Status: {}. Error: {}",
785 status, &error_text
786 )
787 ),
788 }
789}
790
791#[instrument(skip(response), fields(status = %response.status()))]
793async fn process_llm_response(
794 response: Response,
795 input: &[APIInputMessage],
796) -> ChatbotResult<LLMResponse> {
797 if !response.status().is_success() {
798 let status = response.status();
799 let error_text = response.text().await?;
800 error!(
801 status = %status,
802 error = %error_text,
803 input = %summarize_input_for_log(input),
804 "Error calling LLM API"
805 );
806 return Err(llm_http_error(status, error_text));
807 }
808
809 trace!("Processing successful LLM response");
810 let completion: LLMResponse = response.json().await?;
812 debug!(
813 "Successfully processed LLM response with {} choices",
814 completion.output.len()
815 );
816 if let Some(usage) = &completion.usage {
817 usage.log("non_streaming", completion.reasoning.as_ref());
818 }
819 Ok(completion)
820}
821
822#[instrument(skip(chat_request, app_config), fields(
824 num_messages = chat_request.input.len(),
825 temperature,
826 max_tokens
827))]
828pub async fn make_streaming_llm_request(
829 chat_request: &LLMRequest,
830 app_config: &ApplicationConfiguration,
831) -> ChatbotResult<Response> {
832 debug!(
833 "Preparing streaming LLM request with {} messages",
834 chat_request.input.len()
835 );
836 let chatbot_config = azure_chatbot_configuration(app_config)
837 .inspect_err(|_| error!("Azure chatbot configuration missing"))?;
838
839 let request = AzureCompletionRequestRef {
840 include: reasoning_include(&chat_request.params),
841 base: chat_request,
842 stream: true,
843 store: StoreDisabled,
844 };
845
846 let headers = build_llm_headers(&chatbot_config.api_key)?;
847 let api_endpoint = chatbot_config.responses_endpoint()?;
848 debug!(
849 "Sending streaming request to LLM endpoint: {}",
850 api_endpoint
851 );
852
853 let send = REQWEST_STREAMING_CLIENT
854 .post(api_endpoint)
855 .headers(headers)
856 .json(&request)
857 .send();
858 let response = tokio::time::timeout(STREAM_RESPONSE_HEADERS_TIMEOUT, send)
859 .await
860 .map_err(|_| {
861 chatbot_err!(
862 StreamEndedEarly,
863 format!(
864 "The LLM did not send response headers within {} seconds",
865 STREAM_RESPONSE_HEADERS_TIMEOUT.as_secs()
866 )
867 )
868 })??;
869
870 if !response.status().is_success() {
871 let status = response.status();
872 let error_text = response.text().await?;
873 error!(
874 status = %status,
875 error = %error_text,
876 input = %summarize_input_for_log(&request.base.input),
877 "Error calling streaming LLM API"
878 );
879 return Err(llm_http_error(status, error_text));
880 }
881
882 debug!("Successfully initiated streaming response");
883 Ok(response)
884}
885
886#[instrument(skip(chat_request, app_config), fields(
888 num_messages = chat_request.input.len(),
889 temperature,
890 max_tokens
891))]
892pub async fn make_blocking_llm_request(
893 chat_request: LLMRequest,
894 app_config: &ApplicationConfiguration,
895) -> ChatbotResult<LLMResponse> {
896 debug!(
897 "Preparing blocking LLM request with {} messages",
898 chat_request.input.len()
899 );
900 let chatbot_config = azure_chatbot_configuration(app_config)
901 .inspect_err(|_| error!("Azure chatbot configuration missing"))?;
902
903 let api_endpoint = chatbot_config.responses_endpoint()?;
904
905 trace!("Making LLM request to endpoint: {}", api_endpoint);
906 make_llm_request(chat_request, &api_endpoint, &chatbot_config.api_key).await
907}
908
909pub fn parse_text_completion(completion: LLMResponse) -> ChatbotResult<String> {
912 let res =
913 completion
914 .output
915 .into_iter()
916 .map(|x| match x.message_type {
917 OutputItem::Message { content , ..} => Ok(content.get_content_text()),
918 OutputItem::Reasoning { .. } => Ok("".to_string()),
919 _ => Err(chatbot_err!( InvalidMessageShape, "It was assumed this LLM response contains only text, but a tool call or tool response was detected.")),
920 })
921 .collect::<ChatbotResult<Vec<String>>>()?
922 .join("");
923 if res.is_empty() {
924 return Err(chatbot_err!(
925 InvalidMessageShape,
926 "No content returned from LLM"
927 ));
928 };
929 Ok(res)
930}
931
932pub async fn request_structured_json<T: serde::de::DeserializeOwned>(
937 input: Vec<APIInputMessage>,
938 model: String,
939 params: LLMRequestParams,
940 max_output_tokens: Option<i32>,
941 format: LLMRequestResponseFormatParam,
942 app_config: &ApplicationConfiguration,
943 on_invalid_response: impl FnOnce() -> ChatbotError,
944) -> ChatbotResult<T> {
945 let chat_request = LLMRequest {
946 max_output_tokens,
947 text: Some(RequestTextOptions {
948 verbosity: None,
949 format: Some(format),
950 }),
951 ..LLMRequest::new(model, input, params)
952 };
953 let completion = make_blocking_llm_request(chat_request, app_config).await?;
954 let content = parse_text_completion(completion)?;
955 serde_json::from_str(&content).map_err(|_| on_invalid_response())
956}
957
958pub fn get_params_for_model(
959 model_name: &str,
960 model_type: &ModelType,
961 configuration: Option<&ChatbotConfiguration>,
962) -> LLMRequestParams {
963 if model_name == "gpt-5.2-chat" {
964 return LLMRequestParams::GPTThinking(ThinkingParams {
965 reasoning: Some(Reasoning {
966 effort: ReasoningEffortLevel::Medium,
967 summary: Some(SummaryType::Detailed),
968 context: None,
969 }),
970 });
971 }
972 match model_type {
973 ModelType::GPTNonThinking => {
974 if let Some(conf) = configuration {
975 LLMRequestParams::GPTNonThinking(NonThinkingParams {
976 temperature: Some(conf.temperature),
977 top_p: Some(conf.top_p),
978 frequency_penalty: Some(conf.frequency_penalty),
979 presence_penalty: Some(conf.presence_penalty),
980 })
981 } else {
982 LLMRequestParams::GPTNonThinking(NonThinkingParams {
983 temperature: None,
984 top_p: None,
985 frequency_penalty: None,
986 presence_penalty: None,
987 })
988 }
989 }
990 ModelType::GPTHardThinking => {
991 let effort = if let Some(conf) = configuration {
993 if conf.reasoning_effort == ReasoningEffortLevel::Minimal {
994 ReasoningEffortLevel::Low
995 } else {
996 conf.reasoning_effort
997 }
998 } else {
999 ReasoningEffortLevel::None
1000 };
1001 LLMRequestParams::GPTThinking(ThinkingParams {
1002 reasoning: Some(Reasoning {
1003 effort,
1004 summary: Some(SummaryType::Detailed),
1005 context: Some(ReasoningContext::CurrentTurn),
1008 }),
1009 })
1010 }
1011 ModelType::GPTThinking => {
1012 let effort = if let Some(conf) = configuration {
1014 if conf.reasoning_effort == ReasoningEffortLevel::None {
1015 ReasoningEffortLevel::Minimal
1016 } else if conf.reasoning_effort == ReasoningEffortLevel::Xhigh {
1017 ReasoningEffortLevel::High
1018 } else {
1019 conf.reasoning_effort
1020 }
1021 } else {
1022 ReasoningEffortLevel::Minimal
1023 };
1024 LLMRequestParams::GPTThinking(ThinkingParams {
1025 reasoning: Some(Reasoning {
1026 effort,
1027 summary: Some(SummaryType::Detailed),
1028 context: None,
1031 }),
1032 })
1033 }
1034 ModelType::Mistral => LLMRequestParams::Mistral(MistralParams { placeholder: true }),
1035 }
1036}
1037
1038pub fn model_is_thinking(model_type: ModelType) -> bool {
1041 matches!(
1042 model_type,
1043 ModelType::GPTHardThinking | ModelType::GPTThinking
1044 )
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use crate::chatbot_tools::{
1050 ChatbotToolDeclaration,
1051 client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
1052 };
1053
1054 use super::*;
1055
1056 fn thinking_request() -> LLMRequest {
1057 LLMRequest {
1058 input: vec![],
1059 model: "gpt-5.6".to_string(),
1060 tools: vec![],
1061 tool_choice: None,
1062 parallel_tool_calls: None,
1063 max_output_tokens: None,
1064 text: None,
1065 prompt_cache_key: Some("a-key".to_string()),
1066 params: LLMRequestParams::GPTThinking(ThinkingParams {
1067 reasoning: Some(Reasoning {
1068 effort: ReasoningEffortLevel::Medium,
1069 summary: Some(SummaryType::Detailed),
1070 context: None,
1071 }),
1072 }),
1073 }
1074 }
1075
1076 #[test]
1081 fn only_a_reasoning_request_asks_for_the_encrypted_payload() {
1082 let thinking = reasoning_include(&thinking_request().params);
1083 assert_eq!(
1084 thinking.as_deref(),
1085 Some(["reasoning.encrypted_content".to_string()].as_slice())
1086 );
1087
1088 let non_thinking = LLMRequestParams::GPTNonThinking(NonThinkingParams {
1089 temperature: None,
1090 top_p: None,
1091 frequency_penalty: None,
1092 presence_penalty: None,
1093 });
1094 assert_eq!(reasoning_include(&non_thinking), None);
1095 assert_eq!(
1096 reasoning_include(&LLMRequestParams::Mistral(MistralParams {
1097 placeholder: true
1098 })),
1099 None
1100 );
1101
1102 let body = serde_json::to_value(AzureCompletionRequest {
1103 include: reasoning_include(&thinking_request().params),
1104 base: thinking_request(),
1105 stream: true,
1106 store: StoreDisabled,
1107 })
1108 .expect("the request serializes");
1109 assert_eq!(
1110 body["include"],
1111 serde_json::json!(["reasoning.encrypted_content"])
1112 );
1113 }
1114
1115 #[test]
1119 fn a_request_body_can_only_decline_azure_side_storage() {
1120 let body = serde_json::to_value(AzureCompletionRequest {
1121 include: None,
1122 base: thinking_request(),
1123 stream: true,
1124 store: StoreDisabled,
1125 })
1126 .expect("the request serializes");
1127
1128 assert_eq!(body["store"], serde_json::json!(false));
1129 assert!(
1130 serde_json::from_value::<AzureCompletionRequest>(body.clone()).is_ok(),
1131 "the body a request actually sends round-trips"
1132 );
1133
1134 let mut asks_for_storage = body.clone();
1135 asks_for_storage["store"] = serde_json::json!(true);
1136 assert!(
1137 serde_json::from_value::<AzureCompletionRequest>(asks_for_storage).is_err(),
1138 "a body that asks Azure to store the response is not representable"
1139 );
1140
1141 let mut silent = body;
1142 silent
1143 .as_object_mut()
1144 .expect("a request is a JSON object")
1145 .remove("store");
1146 assert!(serde_json::from_value::<AzureCompletionRequest>(silent).is_err());
1147 }
1148
1149 #[test]
1154 fn only_the_model_type_that_is_certainly_gpt_5_6_asks_for_the_current_turn_context() {
1155 let context_of = |model_name: &str, model_type| {
1156 let params = get_params_for_model(model_name, &model_type, None);
1157 serde_json::to_value(params).expect("the params serialize")["reasoning"]["context"]
1158 .clone()
1159 };
1160
1161 assert_eq!(
1162 context_of("gpt-5.6", ModelType::GPTHardThinking),
1163 serde_json::json!("current_turn")
1164 );
1165 assert_eq!(
1166 context_of("gpt-5.4", ModelType::GPTThinking),
1167 serde_json::Value::Null
1168 );
1169 assert_eq!(
1170 context_of("gpt-5.2-chat", ModelType::GPTHardThinking),
1171 serde_json::Value::Null
1172 );
1173 assert_eq!(
1174 context_of("gpt-4.1", ModelType::GPTNonThinking),
1175 serde_json::Value::Null
1176 );
1177 assert_eq!(
1178 context_of("mistral", ModelType::Mistral),
1179 serde_json::Value::Null
1180 );
1181 }
1182
1183 const EVERY_OUTPUT_ITEM_TAG: &[&str] = &[
1187 "azure_ai_search_call",
1188 "azure_ai_search_call_output",
1189 "function_call",
1190 "function_call_output",
1191 "message",
1192 "reasoning",
1193 ];
1194
1195 fn output_item_tag(item: &OutputItem) -> &'static str {
1198 match item {
1199 OutputItem::Message { .. } => "message",
1200 OutputItem::Reasoning { .. } => "reasoning",
1201 OutputItem::AzureAiSearchCall { .. } => "azure_ai_search_call",
1202 OutputItem::AzureAiSearchCallOutput { .. } => "azure_ai_search_call_output",
1203 OutputItem::FunctionCall { .. } => "function_call",
1204 OutputItem::FunctionCallOutput { .. } => "function_call_output",
1205 }
1206 }
1207
1208 fn output_text(text: &str) -> MessageContentItem {
1209 MessageContentItem {
1210 text: text.to_string(),
1211 }
1212 }
1213
1214 fn summary_part(text: &str) -> ReasoningOutput {
1215 ReasoningOutput {
1216 output_type: "summary_text".to_string(),
1217 text: text.to_string(),
1218 }
1219 }
1220
1221 fn round_trip_cases() -> Vec<APIOutputMessage> {
1225 [
1226 OutputItem::Message {
1227 response_id: "resp_1".to_string(),
1228 role: MessageRole::Assistant,
1229 content: MessageContent::Text("Here you go.".to_string()),
1230 },
1231 OutputItem::Message {
1232 response_id: "resp_1".to_string(),
1233 role: MessageRole::Assistant,
1234 content: MessageContent::OutputText(vec![
1235 output_text("First part. "),
1236 output_text("Second part."),
1237 ]),
1238 },
1239 OutputItem::Message {
1240 response_id: "resp_1".to_string(),
1241 role: MessageRole::Assistant,
1242 content: MessageContent::Refusal(vec![RefusalContentItem {
1243 refusal: "I cannot help with that.".to_string(),
1244 }]),
1245 },
1246 OutputItem::Reasoning {
1247 response_id: "resp_1".to_string(),
1248 id: "rs_1".to_string(),
1249 summary: vec![summary_part("First part."), summary_part("Second part.")],
1250 encrypted_content: Some("payload".to_string()),
1251 },
1252 OutputItem::Reasoning {
1253 response_id: "resp_1".to_string(),
1254 id: "rs_2".to_string(),
1255 summary: vec![],
1256 encrypted_content: None,
1257 },
1258 OutputItem::FunctionCall {
1259 response_id: "resp_1".to_string(),
1260 call_id: "call_1".to_string(),
1261 tool_name: "course_progress".to_string(),
1262 arguments: r#"{"query":"loops"}"#.to_string(),
1263 },
1264 OutputItem::FunctionCallOutput {
1265 response_id: "resp_1".to_string(),
1266 call_id: "call_1".to_string(),
1267 output: r#"{"completed":3}"#.to_string(),
1268 },
1269 OutputItem::AzureAiSearchCall {
1270 response_id: "resp_1".to_string(),
1271 call_id: "call_2".to_string(),
1272 arguments: r#"{"query":"loops"}"#.to_string(),
1273 },
1274 OutputItem::AzureAiSearchCallOutput {
1275 response_id: "resp_1".to_string(),
1276 call_id: "call_2".to_string(),
1277 output: r#"{"documents":[]}"#.to_string(),
1278 },
1279 ]
1280 .into_iter()
1281 .map(|message_type| APIOutputMessage { message_type })
1282 .collect()
1283 }
1284
1285 #[test]
1290 fn every_output_item_serializes_the_same_from_memory_as_from_storage() {
1291 let cases = round_trip_cases();
1292
1293 let mut covered: Vec<&str> = cases
1294 .iter()
1295 .map(|case| output_item_tag(&case.message_type))
1296 .collect();
1297 covered.sort_unstable();
1298 covered.dedup();
1299 assert_eq!(
1300 covered, EVERY_OUTPUT_ITEM_TAG,
1301 "every OutputItem variant needs a case"
1302 );
1303
1304 for from_azure in cases {
1305 assert_eq!(
1306 serde_json::to_value(&from_azure.message_type).expect("the item serializes")["type"],
1307 serde_json::json!(output_item_tag(&from_azure.message_type)),
1308 );
1309
1310 let during_the_turn = APIInputMessage::from(from_azure.clone());
1311 let stored = from_azure
1312 .to_chatbot_conversation_message(Uuid::new_v4())
1313 .expect("the item is storable");
1314 let on_a_later_turn = APIInputMessage::try_from(stored).expect("the row converts back");
1315
1316 assert_eq!(
1317 serde_json::to_string(&during_the_turn).expect("the in-memory item serializes"),
1318 serde_json::to_string(&on_a_later_turn).expect("the stored item serializes"),
1319 "{:?}",
1320 from_azure.message_type,
1321 );
1322 }
1323 }
1324
1325 #[test]
1328 fn a_string_list_response_format_is_the_schema_azure_expects() {
1329 let serialized = serde_json::to_value(string_list_response_format(
1330 "AFeatureResponse",
1331 "suggestions",
1332 ))
1333 .expect("the response format serializes");
1334 assert_eq!(
1335 serialized,
1336 serde_json::json!({
1337 "type": "json_schema",
1338 "name": "AFeatureResponse",
1339 "schema": {
1340 "type": "object",
1341 "properties": {
1342 "suggestions": {
1343 "type": "array",
1344 "items": { "type": "string" }
1345 }
1346 },
1347 "required": ["suggestions"],
1348 "additionalProperties": false
1349 },
1350 "strict": true
1351 })
1352 );
1353 }
1354
1355 #[test]
1358 fn the_reasoning_summary_type_serializes_as_the_name_azure_expects() {
1359 assert_eq!(
1360 serde_json::to_string(&SummaryType::Detailed).expect("the summary type serializes"),
1361 r#""detailed""#
1362 );
1363 }
1364
1365 const CLIENT_TOOL_NAME: &str = <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME;
1366
1367 #[test]
1370 fn a_stored_tool_call_gets_its_kind_from_the_tool_name() {
1371 for (tool_name, expected) in [
1372 (CLIENT_TOOL_NAME, ToolKind::ClientTool),
1373 ("course_structure", ToolKind::Function),
1374 ] {
1375 let message = APIOutputMessage {
1376 message_type: OutputItem::FunctionCall {
1377 response_id: "resp_1".to_string(),
1378 call_id: "call_1".to_string(),
1379 tool_name: tool_name.to_string(),
1380 arguments: "{}".to_string(),
1381 },
1382 }
1383 .to_chatbot_conversation_message(Uuid::new_v4())
1384 .expect("the call converts to a conversation message");
1385
1386 let Message::ToolCall(call) = message.message else {
1387 panic!("expected a tool call message");
1388 };
1389 assert_eq!(call.tool_kind, expected, "{tool_name}");
1390 }
1391 }
1392
1393 #[test]
1396 fn a_client_tool_call_and_its_answer_go_back_as_function_items() {
1397 let call = ChatbotConversationMessage {
1398 message: Message::ToolCall(ChatbotConversationMessageToolCall {
1399 tool_name: CLIENT_TOOL_NAME.to_string(),
1400 tool_call_id: "call_1".to_string(),
1401 tool_kind: ToolKind::ClientTool,
1402 response_id: "resp_1".to_string(),
1403 ..Default::default()
1404 }),
1405 ..Default::default()
1406 };
1407 match APIInputMessage::try_from(call)
1408 .expect("the call converts")
1409 .message_type
1410 {
1411 InputItem::FunctionCall {
1412 call_id, tool_name, ..
1413 } => {
1414 assert_eq!(call_id, "call_1");
1415 assert_eq!(tool_name, CLIENT_TOOL_NAME);
1416 }
1417 other => panic!("expected a function call, got {other:?}"),
1418 }
1419
1420 let answer = ChatbotConversationMessage {
1421 message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
1422 output: "the client answered".to_string(),
1423 tool_call_id: "call_1".to_string(),
1424 tool_kind: ToolKind::ClientTool,
1425 response_id: "resp_1".to_string(),
1426 ..Default::default()
1427 }),
1428 ..Default::default()
1429 };
1430 match APIInputMessage::try_from(answer)
1431 .expect("the answer converts")
1432 .message_type
1433 {
1434 InputItem::FunctionCallOutput { call_id, output } => {
1435 assert_eq!(call_id, "call_1");
1436 assert_eq!(output, "the client answered");
1437 }
1438 other => panic!("expected a function call output, got {other:?}"),
1439 }
1440 }
1441
1442 #[test]
1443 fn test_estimate_tokens() {
1444 assert_eq!(estimate_tokens("Hello, world!"), 3);
1446 assert_eq!(estimate_tokens(""), 0);
1447 assert_eq!(
1449 estimate_tokens("This is a longer sentence with several words."),
1450 11
1451 );
1452 assert_eq!(estimate_tokens("Hyvää päivää!"), 7);
1454 assert_eq!(estimate_tokens("トークンは楽しい"), 12);
1456 assert_eq!(
1458 estimate_tokens("🙂🙃😀😃😄😁😆😅😂🤣😊😇🙂🙃😀😃😄😁😆😅😂🤣😊😇"),
1459 48
1460 );
1461 assert_eq!(estimate_tokens("ฉันใช้โทเค็นทุกวัน"), 27);
1463 assert_eq!(estimate_tokens("Жетони роблять мене щасливим"), 25);
1465 }
1466}