1use async_trait::async_trait;
18use futures::StreamExt;
19use reqwest::{Client, RequestBuilder, Url};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::borrow::Cow;
23use std::sync::{Arc, Mutex};
24
25use crate::driver_registry::{
26 ChatDriver, LlmCallConfig, LlmCompletionMetadata, LlmContentPart, LlmMessage,
27 LlmMessageContent, LlmMessageRole, LlmResponseStream, LlmStreamEvent, disjoint_prompt_tokens,
28};
29use crate::error::{AgentLoopError, LlmErrorKind, Result};
30use crate::llm_retry::{
31 LlmRetryConfig, RateLimitInfo, RetryDecision, RetryMetadata, SendOutcome, is_rate_limit_status,
32 retry_request, send_error_message,
33};
34use crate::stream_accumulator::StreamToolCallAccumulator;
35use crate::stream_reconnect::connect_sse_with_reconnect;
36use crate::tool_types::ToolDefinition;
37use crate::user_facing_error::is_provider_quota_message;
38
39const DEFAULT_API_URL: &str = "https://api.openai.com/v1/chat/completions";
40
41pub(crate) fn openai_auth_header_pair<'a>(
49 api_url: &str,
50 api_key: &'a str,
51) -> (&'static str, Cow<'a, str>) {
52 if is_azure_openai_api_url(api_url) {
53 ("api-key", Cow::Borrowed(api_key))
54 } else {
55 ("Authorization", Cow::Owned(format!("Bearer {}", api_key)))
56 }
57}
58
59pub(crate) fn apply_openai_api_auth(
60 request: RequestBuilder,
61 api_url: &str,
62 api_key: &str,
63) -> RequestBuilder {
64 let (name, value) = openai_auth_header_pair(api_url, api_key);
65 request.header(name, value.as_ref())
66}
67
68#[async_trait]
82pub trait AuthHeaderProvider: Send + Sync {
83 async fn auth_header(&self) -> Result<(String, String)>;
87}
88
89pub fn is_azure_openai_api_url(api_url: &str) -> bool {
90 Url::parse(api_url)
91 .ok()
92 .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
93 .is_some_and(|host| {
94 host.ends_with(".openai.azure.com") || host.ends_with(".services.ai.azure.com")
95 })
96}
97
98pub fn is_openai_api_url(api_url: &str) -> bool {
102 Url::parse(api_url)
103 .ok()
104 .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
105 .is_some_and(|host| host == "api.openai.com")
106}
107
108const OPENAI_MODELS_URL: &str = "https://api.openai.com/v1/models";
118
119pub fn url_host_eq(api_url: &str, host: &str) -> bool {
121 Url::parse(api_url)
122 .ok()
123 .and_then(|url| url.host_str().map(str::to_owned))
124 .is_some_and(|h| h.eq_ignore_ascii_case(host))
125}
126
127pub fn normalize_api_url(base_url: &str, endpoint_suffix: &str) -> String {
130 let trimmed = base_url.trim_end_matches('/');
131 if trimmed.ends_with(endpoint_suffix) {
132 trimmed.to_string()
133 } else {
134 format!("{trimmed}{endpoint_suffix}")
135 }
136}
137
138pub fn models_url_for_api_url(api_url: &str) -> String {
140 let trimmed = api_url.trim_end_matches('/');
141
142 if let Some(prefix) = trimmed.strip_suffix("/responses") {
143 return format!("{prefix}/models");
144 }
145 if let Some(prefix) = trimmed.strip_suffix("/chat/completions") {
146 return format!("{prefix}/models");
147 }
148 if trimmed.ends_with("/models") {
149 return trimmed.to_string();
150 }
151 if trimmed.ends_with("/v1") || trimmed.ends_with("/openai/v1") {
152 return format!("{trimmed}/models");
153 }
154
155 OPENAI_MODELS_URL.to_string()
156}
157
158pub fn apply_models_api_auth(
161 request: RequestBuilder,
162 api_url: &str,
163 api_key: &str,
164) -> RequestBuilder {
165 if is_azure_openai_api_url(api_url) {
166 request.header("api-key", api_key)
167 } else {
168 request.bearer_auth(api_key)
169 }
170}
171
172pub fn models_api_status_error(status: reqwest::StatusCode) -> AgentLoopError {
175 AgentLoopError::llm(format!("Models API returned status {status}"))
176}
177
178#[derive(Clone)]
202pub struct OpenAIProtocolChatDriver {
203 client: Client,
204 api_key: String,
205 api_url: String,
206 retry_config: LlmRetryConfig,
208 auth_provider: Option<Arc<dyn AuthHeaderProvider>>,
211}
212
213impl OpenAIProtocolChatDriver {
214 pub fn new(api_key: impl Into<String>) -> Self {
216 Self {
217 client: crate::driver_helpers::shared_streaming_http_client(),
218 api_key: api_key.into(),
219 api_url: DEFAULT_API_URL.to_string(),
220 retry_config: LlmRetryConfig::default(),
221 auth_provider: None,
222 }
223 }
224
225 pub fn with_base_url(api_key: impl Into<String>, api_url: impl Into<String>) -> Self {
227 Self {
228 client: crate::driver_helpers::shared_streaming_http_client(),
229 api_key: api_key.into(),
230 api_url: api_url.into(),
231 retry_config: LlmRetryConfig::default(),
232 auth_provider: None,
233 }
234 }
235
236 pub fn with_retry_config(mut self, config: LlmRetryConfig) -> Self {
238 self.retry_config = config;
239 self
240 }
241
242 pub fn with_auth_provider(mut self, provider: Arc<dyn AuthHeaderProvider>) -> Self {
245 self.auth_provider = Some(provider);
246 self
247 }
248
249 pub fn api_url(&self) -> &str {
251 &self.api_url
252 }
253
254 pub fn api_key(&self) -> &str {
256 &self.api_key
257 }
258
259 pub fn client(&self) -> &Client {
261 &self.client
262 }
263
264 async fn send_chat_completion_request(
273 &self,
274 request: &OpenAiRequest,
275 model: &str,
276 ) -> Result<(reqwest::Response, RetryMetadata)> {
277 let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
278
279 retry_request(
280 &self.retry_config,
281 "OpenAIProtocolDriver",
282 || async {
283 let request_builder = self.client.post(&self.api_url);
287 let request_builder = match &self.auth_provider {
288 Some(provider) => {
289 let (name, value) =
290 provider.auth_header().await.map_err(SendOutcome::Fatal)?;
291 request_builder.header(name, value)
292 }
293 None => apply_openai_api_auth(request_builder, &self.api_url, &self.api_key),
294 };
295 request_builder
296 .header("Content-Type", "application/json")
297 .json(request)
298 .send()
299 .await
300 .map_err(SendOutcome::Send)
301 },
302 |response, attempts, can_retry| {
303 let last_error = Arc::clone(&last_error);
304 let model = model.to_string();
305 async move {
306 let status = response.status();
307
308 if can_retry {
309 let rate_limit_info = if is_rate_limit_status(status) {
311 Some(RateLimitInfo::from_openai_headers(response.headers()))
312 } else {
313 None
314 };
315
316 let error_text = response.text().await.unwrap_or_default();
317
318 if is_openai_request_too_large(status, &error_text) {
320 return RetryDecision::Terminal(AgentLoopError::request_too_large(
321 format!("OpenAI API error ({}): {}", status, error_text),
322 ));
323 }
324
325 if is_provider_quota_message(&error_text) {
328 return RetryDecision::Terminal(AgentLoopError::llm_kind(
329 LlmErrorKind::QuotaExhausted,
330 format!("OpenAI API error ({}): {}", status, error_text),
331 ));
332 }
333
334 let wait = rate_limit_info
335 .as_ref()
336 .map(|info| info.recommended_wait(&self.retry_config, attempts))
337 .unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
338
339 *last_error.lock().unwrap() = Some(error_text);
340 return RetryDecision::Retry {
341 wait,
342 rate_limit_info,
343 };
344 }
345
346 let error_text = response.text().await.unwrap_or_default();
348 let error_msg = format!("OpenAI API error ({}): {}", status, error_text);
349
350 if is_openai_model_not_found(status, &error_text) {
352 return RetryDecision::Terminal(AgentLoopError::model_not_available(model));
353 }
354
355 if is_openai_request_too_large(status, &error_text) {
357 return RetryDecision::Terminal(AgentLoopError::request_too_large(
358 error_msg,
359 ));
360 }
361
362 let kind = LlmErrorKind::from_provider_status(status.as_u16(), &error_text);
365
366 if attempts > 0 {
367 return RetryDecision::Terminal(AgentLoopError::llm_kind(
368 kind,
369 format!(
370 "{} (after {} retries, last error: {})",
371 error_msg,
372 attempts,
373 last_error.lock().unwrap().take().unwrap_or_default()
374 ),
375 ));
376 }
377
378 RetryDecision::Terminal(AgentLoopError::llm_kind(kind, error_msg))
379 }
380 },
381 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
382 )
383 .await
384 }
385
386 fn convert_role(role: &LlmMessageRole) -> &'static str {
387 match role {
388 LlmMessageRole::System => "system",
389 LlmMessageRole::User => "user",
390 LlmMessageRole::Assistant => "assistant",
391 LlmMessageRole::Tool => "tool",
392 }
393 }
394
395 fn convert_message(msg: &LlmMessage) -> OpenAiMessage {
396 let content = match &msg.content {
397 LlmMessageContent::Text(text) => OpenAiContent::Text(text.clone()),
398 LlmMessageContent::Parts(parts) => {
399 let openai_parts: Vec<OpenAiContentPart> = parts
400 .iter()
401 .map(|part| match part {
402 LlmContentPart::Text { text } => OpenAiContentPart::Text {
403 r#type: "text".to_string(),
404 text: text.clone(),
405 },
406 LlmContentPart::Image { url } => OpenAiContentPart::ImageUrl {
407 r#type: "image_url".to_string(),
408 image_url: OpenAiImageUrl { url: url.clone() },
409 },
410 LlmContentPart::Audio { url } => OpenAiContentPart::InputAudio {
411 r#type: "input_audio".to_string(),
412 input_audio: OpenAiInputAudio {
413 data: url.clone(),
414 format: "wav".to_string(),
415 },
416 },
417 })
418 .collect();
419 OpenAiContent::Parts(openai_parts)
420 }
421 };
422
423 let tool_calls = if msg.role == LlmMessageRole::Assistant {
425 msg.tool_calls.as_ref().map(|calls| {
426 calls
427 .iter()
428 .map(|tc| OpenAiToolCall {
429 id: tc.id.clone(),
430 r#type: "function".to_string(),
431 function: OpenAiFunctionCall {
432 name: tc.name.clone(),
433 arguments: serde_json::to_string(&tc.arguments).unwrap_or_default(),
434 },
435 })
436 .collect()
437 })
438 } else {
439 None
440 };
441
442 OpenAiMessage {
443 role: Self::convert_role(&msg.role).to_string(),
444 content: Some(content),
445 tool_calls,
446 tool_call_id: msg.tool_call_id.clone(),
447 }
448 }
449
450 fn convert_tools(tools: &[ToolDefinition]) -> Vec<OpenAiTool> {
451 tools
452 .iter()
453 .map(|tool| OpenAiTool {
454 r#type: "function".to_string(),
455 function: OpenAiFunction {
456 name: tool.name().to_string(),
457 description: tool.description().to_string(),
458 parameters: tool.parameters().clone(),
459 },
460 })
461 .collect()
462 }
463}
464
465fn drop_orphaned_tool_messages(messages: &[LlmMessage]) -> Vec<LlmMessage> {
469 use std::collections::HashSet;
470
471 let visible_call_ids: HashSet<&str> = messages
472 .iter()
473 .filter(|m| m.role == LlmMessageRole::Assistant)
474 .flat_map(|m| m.tool_calls.iter().flatten())
475 .map(|tc| tc.id.as_str())
476 .collect();
477
478 if visible_call_ids.is_empty() {
479 return messages
480 .iter()
481 .filter(|m| m.role != LlmMessageRole::Tool)
482 .cloned()
483 .collect();
484 }
485
486 messages
487 .iter()
488 .filter(|m| {
489 if m.role == LlmMessageRole::Tool {
490 return m
491 .tool_call_id
492 .as_deref()
493 .is_none_or(|id| visible_call_ids.contains(id));
494 }
495 true
496 })
497 .cloned()
498 .collect()
499}
500
501#[async_trait]
502impl ChatDriver for OpenAIProtocolChatDriver {
503 async fn chat_completion_stream(
504 &self,
505 messages: Vec<LlmMessage>,
506 config: &LlmCallConfig,
507 ) -> Result<LlmResponseStream> {
508 let messages = drop_orphaned_tool_messages(&messages);
512 let openai_messages: Vec<OpenAiMessage> =
513 messages.iter().map(Self::convert_message).collect();
514
515 let tools = if config.tools.is_empty() {
516 None
517 } else {
518 Some(Self::convert_tools(&config.tools))
519 };
520
521 let metadata = if config.metadata.is_empty() {
523 None
524 } else {
525 Some(config.metadata.clone())
526 };
527
528 let request = OpenAiRequest {
529 model: config.model.clone(),
530 messages: openai_messages,
531 temperature: config.temperature,
532 max_tokens: config.max_tokens,
533 stream: true,
534 stream_options: Some(OpenAiStreamOptions {
535 include_usage: true,
536 }),
537 tools,
538 parallel_tool_calls: config
539 .resolved_parallel_tool_calls(self.supports_parallel_tool_calls(&config.model)),
540 reasoning_effort: config
542 .reasoning_effort
543 .as_ref()
544 .filter(|e| !e.eq_ignore_ascii_case("none"))
545 .cloned(),
546 service_tier: config.speed.clone(),
547 metadata,
548 };
549
550 let (event_stream, retry_metadata) =
556 connect_sse_with_reconnect(&self.retry_config, "OpenAIProtocolDriver", |_attempt| {
557 self.send_chat_completion_request(&request, &config.model)
558 })
559 .await?;
560
561 let model = config.model.clone();
562 let total_tokens = Arc::new(Mutex::new(0u32));
563 let prompt_tokens = Arc::new(Mutex::new(0u32));
564 let cache_read_tokens = Arc::new(Mutex::new(Option::<u32>::None));
565 let provider_cost_usd = Arc::new(Mutex::new(Option::<f64>::None));
568 let accumulated_tool_calls = Arc::new(Mutex::new(StreamToolCallAccumulator::new()));
569 let finish_reason = Arc::new(Mutex::new(Option::<String>::None));
570 let response_id = Arc::new(Mutex::new(Option::<String>::None));
573 let shared_retry_metadata = if retry_metadata.had_retries() {
575 Some(Arc::new(retry_metadata))
576 } else {
577 None
578 };
579
580 let converted_stream: LlmResponseStream = Box::pin(
584 event_stream
585 .then(move |result| {
586 let model = model.clone();
587 let total_tokens = Arc::clone(&total_tokens);
588 let prompt_tokens = Arc::clone(&prompt_tokens);
589 let cache_read_tokens = Arc::clone(&cache_read_tokens);
590 let provider_cost_usd = Arc::clone(&provider_cost_usd);
591 let accumulated_tool_calls = Arc::clone(&accumulated_tool_calls);
592 let finish_reason = Arc::clone(&finish_reason);
593 let response_id = Arc::clone(&response_id);
594 let retry_metadata_for_done = shared_retry_metadata.clone();
595
596 async move {
597 let event = match result {
598 Ok(event) => event,
599 Err(e) => {
600 return vec![Ok(LlmStreamEvent::Error(
601 format!("Stream error: {}", e).into(),
602 ))];
603 }
604 };
605
606 if event.data == "[DONE]" {
607 let output_tokens = *total_tokens.lock().unwrap();
608 let input_tokens = *prompt_tokens.lock().unwrap();
609 let cached = *cache_read_tokens.lock().unwrap();
610 let cost = *provider_cost_usd.lock().unwrap();
611 let resp_id = response_id.lock().unwrap().clone();
612 let mut reason = finish_reason.lock().unwrap().clone();
613
614 let mut events = Vec::new();
615
616 {
625 let mut acc = accumulated_tool_calls.lock().unwrap();
626 if let Some(event) =
627 take_pending_tool_calls(&mut acc, reason.as_deref())
628 {
629 events.push(Ok(event));
630 reason.get_or_insert_with(|| "tool_calls".to_string());
631 }
632 }
633
634 events.push(Ok(LlmStreamEvent::Done(Box::new(
635 LlmCompletionMetadata {
636 total_tokens: Some(input_tokens + output_tokens),
639 prompt_tokens: Some(disjoint_prompt_tokens(
640 input_tokens,
641 cached,
642 )),
643 completion_tokens: Some(output_tokens),
644 cache_read_tokens: cached,
645 cache_creation_tokens: None,
646 provider_cost_usd: cost,
647 model: Some(model),
648 finish_reason: reason.or_else(|| Some("stop".to_string())),
649 retry_metadata: retry_metadata_for_done
650 .map(|arc| (*arc).clone()),
651 response_id: resp_id,
652 phase: None,
653 },
654 ))));
655
656 return events;
657 }
658
659 match serde_json::from_str::<OpenAiStreamChunk>(&event.data) {
660 Ok(chunk) => {
661 if let Some(id) = &chunk.id {
666 let mut rid = response_id.lock().unwrap();
667 if rid.is_none() {
668 *rid = Some(id.clone());
669 }
670 }
671
672 if let Some(usage) = &chunk.usage {
674 if let Some(pt) = usage.prompt_tokens {
675 *prompt_tokens.lock().unwrap() = pt;
676 }
677 if let Some(ct) = usage.completion_tokens {
678 *total_tokens.lock().unwrap() = ct;
679 }
680 if let Some(details) = &usage.prompt_tokens_details
682 && details.cached_tokens.is_some()
683 {
684 *cache_read_tokens.lock().unwrap() = details.cached_tokens;
685 }
686 if usage.cost.is_some() {
689 *provider_cost_usd.lock().unwrap() = usage.cost;
690 }
691 }
692
693 if let Some(choice) = chunk.choices.first() {
694 let mut tt = total_tokens.lock().unwrap();
695 let mut acc = accumulated_tool_calls.lock().unwrap();
696 let mut fr = finish_reason.lock().unwrap();
697 let stream_event =
698 process_stream_choice(choice, &mut tt, &mut acc, &mut fr);
699 return vec![Ok(stream_event)];
700 }
701 vec![Ok(LlmStreamEvent::TextDelta(String::new()))]
702 }
703 Err(e) => vec![Ok(LlmStreamEvent::Error(
704 format!("Failed to parse chunk: {}", e).into(),
705 ))],
706 }
707 }
708 })
709 .flat_map(futures::stream::iter),
710 );
711
712 Ok(converted_stream)
713 }
714
715 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
719 true
720 }
721}
722
723impl std::fmt::Debug for OpenAIProtocolChatDriver {
724 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725 f.debug_struct("OpenAIProtocolChatDriver")
726 .field("api_url", &self.api_url)
727 .field("api_key", &"[REDACTED]")
728 .finish()
729 }
730}
731
732pub fn is_openai_model_not_found(status: reqwest::StatusCode, error_text: &str) -> bool {
743 let error_lower = error_text.to_lowercase();
744
745 if status == reqwest::StatusCode::NOT_FOUND
747 || status == reqwest::StatusCode::BAD_REQUEST
748 || status == reqwest::StatusCode::FORBIDDEN
749 {
750 if error_lower.contains("model_not_found") {
752 return true;
753 }
754 }
755
756 if status == reqwest::StatusCode::NOT_FOUND {
758 if error_lower.contains("does not exist") {
759 return true;
760 }
761 if error_lower.contains("model") && error_lower.contains("not found") {
762 return true;
763 }
764 }
765
766 false
767}
768
769pub fn is_openai_request_too_large(status: reqwest::StatusCode, error_text: &str) -> bool {
776 let error_lower = error_text.to_lowercase();
777
778 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
780 if error_lower.contains("request too large") {
782 return true;
783 }
784 if error_lower.contains("tokens") && error_lower.contains("limit") {
786 return true;
787 }
788 }
789
790 if status == reqwest::StatusCode::BAD_REQUEST {
792 if error_lower.contains("context_length_exceeded") {
794 return true;
795 }
796 if error_lower.contains("maximum context length") {
798 return true;
799 }
800 }
801
802 if error_lower.contains("tokens must be reduced")
804 || error_lower.contains("reduce the length")
805 || error_lower.contains("input is too long")
806 {
807 return true;
808 }
809
810 false
811}
812
813#[derive(Debug, Serialize)]
818struct OpenAiRequest {
819 model: String,
820 messages: Vec<OpenAiMessage>,
821 #[serde(skip_serializing_if = "Option::is_none")]
822 temperature: Option<f32>,
823 #[serde(skip_serializing_if = "Option::is_none")]
824 max_tokens: Option<u32>,
825 stream: bool,
826 #[serde(skip_serializing_if = "Option::is_none")]
828 stream_options: Option<OpenAiStreamOptions>,
829 #[serde(skip_serializing_if = "Option::is_none")]
830 tools: Option<Vec<OpenAiTool>>,
831 #[serde(skip_serializing_if = "Option::is_none")]
834 parallel_tool_calls: Option<bool>,
835 #[serde(skip_serializing_if = "Option::is_none")]
836 reasoning_effort: Option<String>,
837 #[serde(skip_serializing_if = "Option::is_none")]
840 service_tier: Option<String>,
841 #[serde(skip_serializing_if = "Option::is_none")]
844 metadata: Option<std::collections::HashMap<String, String>>,
845}
846
847#[derive(Debug, Serialize)]
848struct OpenAiStreamOptions {
849 include_usage: bool,
850}
851
852#[derive(Debug, Serialize, Deserialize)]
853#[serde(untagged)]
854enum OpenAiContent {
855 Text(String),
856 Parts(Vec<OpenAiContentPart>),
857}
858
859#[derive(Debug, Serialize, Deserialize)]
860#[serde(untagged)]
861enum OpenAiContentPart {
862 Text {
863 r#type: String,
864 text: String,
865 },
866 ImageUrl {
867 r#type: String,
868 image_url: OpenAiImageUrl,
869 },
870 InputAudio {
871 r#type: String,
872 input_audio: OpenAiInputAudio,
873 },
874}
875
876#[derive(Debug, Serialize, Deserialize)]
877struct OpenAiImageUrl {
878 url: String,
879}
880
881#[derive(Debug, Serialize, Deserialize)]
882struct OpenAiInputAudio {
883 data: String,
884 format: String,
885}
886
887#[derive(Debug, Serialize, Deserialize)]
888struct OpenAiMessage {
889 role: String,
890 #[serde(skip_serializing_if = "Option::is_none")]
891 content: Option<OpenAiContent>,
892 #[serde(skip_serializing_if = "Option::is_none")]
893 tool_calls: Option<Vec<OpenAiToolCall>>,
894 #[serde(skip_serializing_if = "Option::is_none")]
895 tool_call_id: Option<String>,
896}
897
898#[derive(Debug, Serialize, Deserialize)]
899struct OpenAiTool {
900 r#type: String,
901 function: OpenAiFunction,
902}
903
904#[derive(Debug, Serialize, Deserialize)]
905struct OpenAiFunction {
906 name: String,
907 description: String,
908 parameters: Value,
909}
910
911#[derive(Debug, Serialize, Deserialize)]
912struct OpenAiToolCall {
913 id: String,
914 r#type: String,
915 function: OpenAiFunctionCall,
916}
917
918#[derive(Debug, Serialize, Deserialize)]
919struct OpenAiFunctionCall {
920 name: String,
921 arguments: String,
922}
923
924#[derive(Debug, Deserialize)]
925#[allow(dead_code)] struct OpenAiStreamChunk {
927 #[serde(default)]
929 id: Option<String>,
930 #[serde(default)]
932 model: Option<String>,
933 choices: Vec<OpenAiStreamChoice>,
934 #[serde(default)]
935 usage: Option<OpenAiUsage>,
936}
937
938#[derive(Debug, Deserialize)]
939struct OpenAiUsage {
940 prompt_tokens: Option<u32>,
941 completion_tokens: Option<u32>,
942 #[serde(default)]
944 prompt_tokens_details: Option<OpenAiPromptTokensDetails>,
945 #[serde(default)]
948 cost: Option<f64>,
949}
950
951#[derive(Debug, Deserialize, Default)]
952struct OpenAiPromptTokensDetails {
953 #[serde(default)]
955 cached_tokens: Option<u32>,
956}
957
958#[derive(Debug, Deserialize)]
959struct OpenAiStreamChoice {
960 delta: OpenAiDelta,
961 #[serde(default)]
962 finish_reason: Option<String>,
963}
964
965#[derive(Debug, Deserialize)]
966struct OpenAiDelta {
967 #[serde(default)]
968 content: Option<String>,
969 #[serde(default)]
970 tool_calls: Option<Vec<OpenAiStreamToolCall>>,
971}
972
973#[derive(Debug, Deserialize)]
974struct OpenAiStreamToolCall {
975 index: u32,
976 id: Option<String>,
977 function: Option<OpenAiStreamFunction>,
978}
979
980#[derive(Debug, Deserialize)]
981struct OpenAiStreamFunction {
982 name: Option<String>,
983 arguments: Option<String>,
984}
985
986fn take_pending_tool_calls(
997 accumulated_tool_calls: &mut StreamToolCallAccumulator,
998 finish_reason: Option<&str>,
999) -> Option<LlmStreamEvent> {
1000 if accumulated_tool_calls.is_empty() {
1001 return None;
1002 }
1003
1004 if !matches!(finish_reason, None | Some("tool_calls")) {
1007 let _ = accumulated_tool_calls.take_finalized();
1008 return None;
1009 }
1010
1011 let calls = accumulated_tool_calls.take_pending_strict();
1012 if calls.is_empty() {
1013 None
1014 } else {
1015 Some(LlmStreamEvent::ToolCalls(calls))
1016 }
1017}
1018
1019fn process_stream_choice(
1029 choice: &OpenAiStreamChoice,
1030 total_tokens: &mut u32,
1031 accumulated_tool_calls: &mut StreamToolCallAccumulator,
1032 finish_reason: &mut Option<String>,
1033) -> LlmStreamEvent {
1034 if let Some(tool_calls) = &choice.delta.tool_calls {
1038 for tc in tool_calls {
1039 accumulated_tool_calls.apply_indexed_delta(
1040 tc.index,
1041 tc.id.as_deref(),
1042 tc.function.as_ref().and_then(|f| f.name.as_deref()),
1043 tc.function.as_ref().and_then(|f| f.arguments.as_deref()),
1044 );
1045 }
1046 return LlmStreamEvent::TextDelta(String::new());
1047 }
1048
1049 if let Some(content) = &choice.delta.content
1052 && !content.is_empty()
1053 {
1054 *total_tokens += 1;
1055 return LlmStreamEvent::TextDelta(content.clone());
1056 }
1057
1058 if let Some(fr) = &choice.finish_reason {
1062 *finish_reason = Some(fr.clone());
1063
1064 if fr == "tool_calls" && !accumulated_tool_calls.is_empty() {
1065 return LlmStreamEvent::ToolCalls(accumulated_tool_calls.take_finalized());
1066 }
1067 }
1068
1069 LlmStreamEvent::TextDelta(String::new())
1070}
1071
1072#[cfg(test)]
1077mod tests {
1078 use super::*;
1079 use serde_json::json;
1080
1081 #[test]
1082 fn test_convert_message_preserves_multiple_system_messages() {
1083 let messages = [
1088 LlmMessage::text(LlmMessageRole::System, "A"),
1089 LlmMessage::text(LlmMessageRole::User, "hi"),
1090 LlmMessage::text(LlmMessageRole::System, "B"),
1091 ];
1092 let converted: Vec<OpenAiMessage> = messages
1093 .iter()
1094 .map(OpenAIProtocolChatDriver::convert_message)
1095 .collect();
1096 let system_texts: Vec<String> = converted
1097 .iter()
1098 .filter(|m| m.role == "system")
1099 .filter_map(|m| match &m.content {
1100 Some(OpenAiContent::Text(t)) => Some(t.clone()),
1101 _ => None,
1102 })
1103 .collect();
1104 assert_eq!(system_texts, vec!["A".to_string(), "B".to_string()]);
1105 }
1106
1107 #[test]
1108 fn test_driver_with_api_key() {
1109 let driver = OpenAIProtocolChatDriver::new("test-key");
1110 assert!(format!("{:?}", driver).contains("OpenAIProtocolChatDriver"));
1111 }
1112
1113 #[test]
1114 fn test_driver_with_base_url() {
1115 let driver = OpenAIProtocolChatDriver::with_base_url(
1116 "test-key",
1117 "https://custom.api.com/v1/completions",
1118 );
1119 assert!(format!("{:?}", driver).contains("OpenAIProtocolChatDriver"));
1120 assert_eq!(driver.api_url(), "https://custom.api.com/v1/completions");
1121 }
1122
1123 #[test]
1124 fn test_is_azure_openai_api_url() {
1125 assert!(is_azure_openai_api_url(
1126 "https://example.openai.azure.com/openai/v1/chat/completions"
1127 ));
1128 assert!(is_azure_openai_api_url(
1129 "https://example.services.ai.azure.com/openai/v1/responses"
1130 ));
1131 assert!(!is_azure_openai_api_url(
1132 "https://api.openai.com/v1/chat/completions"
1133 ));
1134 }
1135
1136 #[test]
1137 fn test_request_includes_stream_options_for_usage() {
1138 let request = OpenAiRequest {
1141 service_tier: None,
1142 model: "gpt-4o".to_string(),
1143 messages: vec![OpenAiMessage {
1144 role: "user".to_string(),
1145 content: Some(OpenAiContent::Text("Hello".to_string())),
1146 tool_calls: None,
1147 tool_call_id: None,
1148 }],
1149 temperature: None,
1150 max_tokens: None,
1151 stream: true,
1152 stream_options: Some(OpenAiStreamOptions {
1153 include_usage: true,
1154 }),
1155 tools: None,
1156 parallel_tool_calls: None,
1157 reasoning_effort: None,
1158 metadata: None,
1159 };
1160
1161 let json = serde_json::to_value(&request).unwrap();
1162 assert_eq!(json["stream"], true);
1163 assert_eq!(json["stream_options"]["include_usage"], true);
1164 }
1165
1166 #[test]
1167 fn test_request_includes_metadata() {
1168 let mut metadata = std::collections::HashMap::new();
1170 metadata.insert("session_id".to_string(), "session_abc123".to_string());
1171 metadata.insert("agent_id".to_string(), "agent_xyz789".to_string());
1172
1173 let request = OpenAiRequest {
1174 service_tier: None,
1175 model: "gpt-4o".to_string(),
1176 messages: vec![OpenAiMessage {
1177 role: "user".to_string(),
1178 content: Some(OpenAiContent::Text("Hello".to_string())),
1179 tool_calls: None,
1180 tool_call_id: None,
1181 }],
1182 temperature: None,
1183 max_tokens: None,
1184 stream: true,
1185 stream_options: None,
1186 tools: None,
1187 parallel_tool_calls: None,
1188 reasoning_effort: None,
1189 metadata: Some(metadata),
1190 };
1191
1192 let json = serde_json::to_value(&request).unwrap();
1193 assert_eq!(json["metadata"]["session_id"], "session_abc123");
1194 assert_eq!(json["metadata"]["agent_id"], "agent_xyz789");
1195 }
1196
1197 #[test]
1198 fn test_usage_chunk_parsing() {
1199 let usage_chunk = r#"{
1202 "id": "chatcmpl-123",
1203 "object": "chat.completion.chunk",
1204 "created": 1234567890,
1205 "model": "gpt-4o",
1206 "choices": [],
1207 "usage": {
1208 "prompt_tokens": 150,
1209 "completion_tokens": 42,
1210 "total_tokens": 192
1211 }
1212 }"#;
1213
1214 let chunk: OpenAiStreamChunk = serde_json::from_str(usage_chunk).unwrap();
1215 assert!(chunk.usage.is_some());
1216 let usage = chunk.usage.unwrap();
1217 assert_eq!(usage.prompt_tokens, Some(150));
1218 assert_eq!(usage.completion_tokens, Some(42));
1219 }
1220
1221 #[test]
1222 fn test_usage_chunk_with_cached_tokens() {
1223 let usage_chunk = r#"{
1225 "id": "chatcmpl-123",
1226 "choices": [],
1227 "usage": {
1228 "prompt_tokens": 150,
1229 "completion_tokens": 42,
1230 "prompt_tokens_details": {
1231 "cached_tokens": 100
1232 }
1233 }
1234 }"#;
1235
1236 let chunk: OpenAiStreamChunk = serde_json::from_str(usage_chunk).unwrap();
1237 let usage = chunk.usage.unwrap();
1238 assert_eq!(usage.prompt_tokens, Some(150));
1239 assert_eq!(usage.completion_tokens, Some(42));
1240 assert!(usage.prompt_tokens_details.is_some());
1241 assert_eq!(
1242 usage.prompt_tokens_details.unwrap().cached_tokens,
1243 Some(100)
1244 );
1245 }
1246
1247 #[test]
1248 fn test_usage_chunk_with_openrouter_cost() {
1249 let usage_chunk = r#"{
1251 "id": "gen-123",
1252 "choices": [],
1253 "usage": {
1254 "prompt_tokens": 194,
1255 "completion_tokens": 2,
1256 "total_tokens": 196,
1257 "cost": 0.00095
1258 }
1259 }"#;
1260
1261 let chunk: OpenAiStreamChunk = serde_json::from_str(usage_chunk).unwrap();
1262 let usage = chunk.usage.unwrap();
1263 assert_eq!(usage.cost, Some(0.00095));
1264 }
1265
1266 #[test]
1267 fn test_usage_chunk_without_cost_defaults_none() {
1268 let usage_chunk = r#"{
1270 "id": "chatcmpl-123",
1271 "choices": [],
1272 "usage": { "prompt_tokens": 10, "completion_tokens": 5 }
1273 }"#;
1274
1275 let chunk: OpenAiStreamChunk = serde_json::from_str(usage_chunk).unwrap();
1276 assert_eq!(chunk.usage.unwrap().cost, None);
1277 }
1278
1279 #[test]
1280 fn test_chunk_id_is_captured() {
1281 let chunk_with_id: OpenAiStreamChunk =
1282 serde_json::from_str(r#"{"id":"gen-abc123","choices":[]}"#).unwrap();
1283 assert_eq!(chunk_with_id.id.as_deref(), Some("gen-abc123"));
1284
1285 let chunk_no_id: OpenAiStreamChunk = serde_json::from_str(r#"{"choices":[]}"#).unwrap();
1286 assert!(chunk_no_id.id.is_none());
1287 }
1288
1289 #[test]
1290 fn test_finish_reason_chunk_parsing() {
1291 let finish_chunk = r#"{
1293 "id": "chatcmpl-123",
1294 "choices": [{
1295 "index": 0,
1296 "delta": {},
1297 "finish_reason": "stop"
1298 }]
1299 }"#;
1300
1301 let chunk: OpenAiStreamChunk = serde_json::from_str(finish_chunk).unwrap();
1302 assert!(chunk.usage.is_none()); assert_eq!(chunk.choices.len(), 1);
1304 assert_eq!(chunk.choices[0].finish_reason, Some("stop".to_string()));
1305 }
1306
1307 #[test]
1312 fn test_is_openai_request_too_large_429_request_too_large() {
1313 let error = r#"{"error":{"message":"Request too large for gpt-4o in organization org-xxx on tokens per min (TPM): Limit 500000, Requested 538772."}}"#;
1314 assert!(is_openai_request_too_large(
1315 reqwest::StatusCode::TOO_MANY_REQUESTS,
1316 error
1317 ));
1318 }
1319
1320 #[test]
1321 fn test_is_openai_request_too_large_429_token_limit() {
1322 let error =
1323 r#"{"error":{"message":"tokens per min (TPM): Limit 500000, Requested 600000"}}"#;
1324 assert!(is_openai_request_too_large(
1325 reqwest::StatusCode::TOO_MANY_REQUESTS,
1326 error
1327 ));
1328 }
1329
1330 #[test]
1331 fn test_is_openai_request_too_large_400_context_length() {
1332 let error = r#"{"error":{"code":"context_length_exceeded","message":"This model's maximum context length is 128000 tokens."}}"#;
1333 assert!(is_openai_request_too_large(
1334 reqwest::StatusCode::BAD_REQUEST,
1335 error
1336 ));
1337 }
1338
1339 #[test]
1340 fn test_is_openai_request_too_large_400_max_context() {
1341 let error =
1342 r#"{"error":{"message":"This model's maximum context length is 128000 tokens"}}"#;
1343 assert!(is_openai_request_too_large(
1344 reqwest::StatusCode::BAD_REQUEST,
1345 error
1346 ));
1347 }
1348
1349 #[test]
1350 fn test_is_openai_request_too_large_tokens_must_be_reduced() {
1351 let error = r#"{"error":{"message":"The input or output tokens must be reduced"}}"#;
1352 assert!(is_openai_request_too_large(
1353 reqwest::StatusCode::BAD_REQUEST,
1354 error
1355 ));
1356 }
1357
1358 #[test]
1359 fn test_is_openai_request_too_large_false_for_other_errors() {
1360 let error = r#"{"error":{"message":"Rate limit exceeded: too many requests per minute"}}"#;
1362 assert!(!is_openai_request_too_large(
1363 reqwest::StatusCode::TOO_MANY_REQUESTS,
1364 error
1365 ));
1366
1367 let error = r#"{"error":{"message":"Internal server error"}}"#;
1369 assert!(!is_openai_request_too_large(
1370 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1371 error
1372 ));
1373
1374 let error = r#"{"error":{"message":"Invalid request"}}"#;
1376 assert!(!is_openai_request_too_large(
1377 reqwest::StatusCode::BAD_REQUEST,
1378 error
1379 ));
1380 }
1381
1382 #[test]
1387 fn test_is_openai_model_not_found_real_error() {
1388 let error = r#"{"error":{"code":"model_not_found","message":"The model 'gpt-99' does not exist or you do not have access to it.","type":"invalid_request_error","param":null}}"#;
1390 assert!(is_openai_model_not_found(
1391 reqwest::StatusCode::NOT_FOUND,
1392 error
1393 ));
1394 }
1395
1396 #[test]
1397 fn test_is_openai_model_not_found_does_not_exist() {
1398 let error = r#"{"error":{"message":"The model 'fake-model' does not exist"}}"#;
1399 assert!(is_openai_model_not_found(
1400 reqwest::StatusCode::NOT_FOUND,
1401 error
1402 ));
1403 }
1404
1405 #[test]
1406 fn test_is_openai_model_not_found_generic_not_found() {
1407 let error = r#"{"error":{"message":"Model not found"}}"#;
1408 assert!(is_openai_model_not_found(
1409 reqwest::StatusCode::NOT_FOUND,
1410 error
1411 ));
1412 }
1413
1414 #[test]
1415 fn test_is_openai_model_not_found_400_with_model_not_found_code() {
1416 let error = r#"{"error":{"code":"model_not_found","message":"The requested model 'gpt-99' does not exist.","type":"invalid_request_error","param":"model"}}"#;
1418 assert!(is_openai_model_not_found(
1419 reqwest::StatusCode::BAD_REQUEST,
1420 error
1421 ));
1422 }
1423
1424 #[test]
1425 fn test_is_openai_model_not_found_false_for_non_model_error() {
1426 let error = r#"{"error":{"code":"invalid_request","message":"Some other error"}}"#;
1428 assert!(!is_openai_model_not_found(
1429 reqwest::StatusCode::BAD_REQUEST,
1430 error
1431 ));
1432 }
1433
1434 #[test]
1435 fn test_is_openai_model_not_found_false_for_other_404() {
1436 let error = r#"{"error":{"message":"Endpoint not found"}}"#;
1438 assert!(!is_openai_model_not_found(
1439 reqwest::StatusCode::NOT_FOUND,
1440 error
1441 ));
1442 }
1443
1444 #[test]
1445 fn test_is_openai_model_not_found_403_tier_gated_model() {
1446 let error = r#"{"error":{"code":"model_not_found","message":"The model 'gpt-5.4-mini' does not exist or you do not have access to it.","type":"invalid_request_error","param":null}}"#;
1449 assert!(is_openai_model_not_found(
1450 reqwest::StatusCode::FORBIDDEN,
1451 error
1452 ));
1453 }
1454
1455 #[test]
1456 fn test_is_openai_model_not_found_403_plain_auth_error_is_not_model_not_found() {
1457 let error = r#"{"error":{"message":"Invalid authentication credentials","type":"authentication_error"}}"#;
1460 assert!(!is_openai_model_not_found(
1461 reqwest::StatusCode::FORBIDDEN,
1462 error
1463 ));
1464 }
1465
1466 #[test]
1471 fn test_reasoning_effort_none_is_omitted() {
1472 let request = OpenAiRequest {
1475 service_tier: None,
1476 model: "gpt-4o-mini".to_string(),
1477 messages: vec![OpenAiMessage {
1478 role: "user".to_string(),
1479 content: Some(OpenAiContent::Text("Hello".to_string())),
1480 tool_calls: None,
1481 tool_call_id: None,
1482 }],
1483 temperature: None,
1484 max_tokens: None,
1485 stream: true,
1486 stream_options: None,
1487 tools: None,
1488 parallel_tool_calls: None,
1489 reasoning_effort: Some("none".to_string())
1490 .as_ref()
1491 .filter(|e| !e.eq_ignore_ascii_case("none"))
1492 .cloned(),
1493 metadata: None,
1494 };
1495
1496 let json = serde_json::to_value(&request).unwrap();
1497 assert!(
1498 json.get("reasoning_effort").is_none(),
1499 "reasoning_effort should be omitted when effort is 'none'"
1500 );
1501 }
1502
1503 #[test]
1504 fn test_reasoning_effort_high_is_included() {
1505 let request = OpenAiRequest {
1506 service_tier: None,
1507 model: "o3-mini".to_string(),
1508 messages: vec![OpenAiMessage {
1509 role: "user".to_string(),
1510 content: Some(OpenAiContent::Text("Hello".to_string())),
1511 tool_calls: None,
1512 tool_call_id: None,
1513 }],
1514 temperature: None,
1515 max_tokens: None,
1516 stream: true,
1517 stream_options: None,
1518 tools: None,
1519 parallel_tool_calls: None,
1520 reasoning_effort: Some("high".to_string())
1521 .as_ref()
1522 .filter(|e| !e.eq_ignore_ascii_case("none"))
1523 .cloned(),
1524 metadata: None,
1525 };
1526
1527 let json = serde_json::to_value(&request).unwrap();
1528 assert_eq!(json["reasoning_effort"], "high");
1529 }
1530
1531 #[test]
1535 fn test_request_serializes_parallel_tool_calls() {
1536 fn build(flag: Option<bool>) -> serde_json::Value {
1537 let request = OpenAiRequest {
1538 service_tier: None,
1539 model: "gpt-4o-mini".to_string(),
1540 messages: vec![OpenAiMessage {
1541 role: "user".to_string(),
1542 content: Some(OpenAiContent::Text("Hello".to_string())),
1543 tool_calls: None,
1544 tool_call_id: None,
1545 }],
1546 temperature: None,
1547 max_tokens: None,
1548 stream: true,
1549 stream_options: None,
1550 tools: None,
1551 parallel_tool_calls: flag,
1552 reasoning_effort: None,
1553 metadata: None,
1554 };
1555 serde_json::to_value(&request).unwrap()
1556 }
1557
1558 assert!(build(None).get("parallel_tool_calls").is_none());
1560 assert_eq!(build(Some(true))["parallel_tool_calls"], true);
1562 assert_eq!(build(Some(false))["parallel_tool_calls"], false);
1563 }
1564
1565 #[test]
1568 fn test_request_serializes_service_tier() {
1569 fn build(tier: Option<&str>) -> serde_json::Value {
1570 let request = OpenAiRequest {
1571 service_tier: tier.map(str::to_string),
1572 model: "gpt-4o-mini".to_string(),
1573 messages: vec![OpenAiMessage {
1574 role: "user".to_string(),
1575 content: Some(OpenAiContent::Text("Hello".to_string())),
1576 tool_calls: None,
1577 tool_call_id: None,
1578 }],
1579 temperature: None,
1580 max_tokens: None,
1581 stream: true,
1582 stream_options: None,
1583 tools: None,
1584 parallel_tool_calls: None,
1585 reasoning_effort: None,
1586 metadata: None,
1587 };
1588 serde_json::to_value(&request).unwrap()
1589 }
1590
1591 assert!(build(None).get("service_tier").is_none());
1592 assert_eq!(build(Some("flex"))["service_tier"], "flex");
1593 assert_eq!(build(Some("priority"))["service_tier"], "priority");
1594 }
1595
1596 fn choice(json_str: &str) -> OpenAiStreamChoice {
1601 serde_json::from_str(json_str).unwrap()
1602 }
1603
1604 #[test]
1608 fn test_empty_content_finish_chunk_still_emits_tool_calls() {
1609 let mut total_tokens = 0u32;
1610 let mut acc = StreamToolCallAccumulator::new();
1611 let mut finish_reason: Option<String> = None;
1612
1613 let e = process_stream_choice(
1615 &choice(
1616 r#"{"delta":{"content":null,"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}"#,
1617 ),
1618 &mut total_tokens,
1619 &mut acc,
1620 &mut finish_reason,
1621 );
1622 assert!(matches!(e, LlmStreamEvent::TextDelta(s) if s.is_empty()));
1623
1624 let e = process_stream_choice(
1626 &choice(
1627 r#"{"delta":{"content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\"Cargo.toml\"}"}}]},"finish_reason":null}"#,
1628 ),
1629 &mut total_tokens,
1630 &mut acc,
1631 &mut finish_reason,
1632 );
1633 assert!(matches!(e, LlmStreamEvent::TextDelta(s) if s.is_empty()));
1634
1635 let e = process_stream_choice(
1638 &choice(r#"{"delta":{"content":""},"finish_reason":"tool_calls"}"#),
1639 &mut total_tokens,
1640 &mut acc,
1641 &mut finish_reason,
1642 );
1643 match e {
1644 LlmStreamEvent::ToolCalls(calls) => {
1645 assert_eq!(calls.len(), 1);
1646 assert_eq!(calls[0].id, "call_1");
1647 assert_eq!(calls[0].name, "read_file");
1648 assert_eq!(calls[0].arguments, json!({"path": "Cargo.toml"}));
1649 }
1650 other => panic!("expected ToolCalls, got {:?}", other),
1651 }
1652 assert_eq!(finish_reason.as_deref(), Some("tool_calls"));
1653
1654 let e = process_stream_choice(
1657 &choice(r#"{"delta":{"content":""},"finish_reason":"tool_calls"}"#),
1658 &mut total_tokens,
1659 &mut acc,
1660 &mut finish_reason,
1661 );
1662 assert!(
1663 matches!(e, LlmStreamEvent::TextDelta(s) if s.is_empty()),
1664 "tool calls must only be emitted once"
1665 );
1666 }
1667
1668 #[test]
1670 fn test_non_empty_content_is_emitted() {
1671 let mut total_tokens = 0u32;
1672 let mut acc = StreamToolCallAccumulator::new();
1673 let mut finish_reason: Option<String> = None;
1674
1675 let e = process_stream_choice(
1676 &choice(r#"{"delta":{"content":"hello"},"finish_reason":null}"#),
1677 &mut total_tokens,
1678 &mut acc,
1679 &mut finish_reason,
1680 );
1681 assert!(matches!(e, LlmStreamEvent::TextDelta(s) if s == "hello"));
1682 assert_eq!(total_tokens, 1);
1683 }
1684
1685 #[test]
1689 fn test_tool_call_arguments_accumulate_across_many_chunks() {
1690 let mut total_tokens = 0u32;
1691 let mut acc = StreamToolCallAccumulator::new();
1692 let mut finish_reason: Option<String> = None;
1693
1694 process_stream_choice(
1696 &choice(
1697 r#"{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}"#,
1698 ),
1699 &mut total_tokens,
1700 &mut acc,
1701 &mut finish_reason,
1702 );
1703
1704 let payload = r#"{"path":"a.rs","contents":"a fairly long contents value streamed one character at a time to exceed one hundred chunks","n":987654321}"#;
1705 assert!(payload.chars().count() > 100);
1706
1707 let mut expected = String::new();
1709 for ch in payload.chars() {
1710 let frag = ch.to_string();
1711 let chunk = json!({
1712 "delta": {"tool_calls": [{"index": 0, "function": {"arguments": frag}}]},
1713 "finish_reason": null
1714 })
1715 .to_string();
1716 process_stream_choice(
1717 &choice(&chunk),
1718 &mut total_tokens,
1719 &mut acc,
1720 &mut finish_reason,
1721 );
1722 expected.push_str(&frag);
1723 }
1724
1725 let e = process_stream_choice(
1731 &choice(r#"{"delta":{},"finish_reason":"tool_calls"}"#),
1732 &mut total_tokens,
1733 &mut acc,
1734 &mut finish_reason,
1735 );
1736 match e {
1737 LlmStreamEvent::ToolCalls(calls) => {
1738 assert_eq!(calls.len(), 1);
1739 assert_eq!(calls[0].id, "call_1");
1740 assert_eq!(
1741 calls[0].arguments,
1742 serde_json::from_str::<serde_json::Value>(payload).unwrap()
1743 );
1744 }
1745 other => panic!("expected ToolCalls, got {:?}", other),
1746 }
1747 }
1748
1749 #[test]
1752 fn test_finish_chunk_without_content_emits_tool_calls() {
1753 let mut total_tokens = 0u32;
1754 let mut acc = StreamToolCallAccumulator::new();
1755 let mut finish_reason: Option<String> = None;
1756
1757 process_stream_choice(
1758 &choice(
1759 r#"{"delta":{"tool_calls":[{"index":0,"id":"call_9","function":{"name":"list_dir","arguments":"{}"}}]},"finish_reason":null}"#,
1760 ),
1761 &mut total_tokens,
1762 &mut acc,
1763 &mut finish_reason,
1764 );
1765
1766 let e = process_stream_choice(
1767 &choice(r#"{"delta":{},"finish_reason":"tool_calls"}"#),
1768 &mut total_tokens,
1769 &mut acc,
1770 &mut finish_reason,
1771 );
1772 match e {
1773 LlmStreamEvent::ToolCalls(calls) => {
1774 assert_eq!(calls.len(), 1);
1775 assert_eq!(calls[0].name, "list_dir");
1776 }
1777 other => panic!("expected ToolCalls, got {:?}", other),
1778 }
1779 }
1780
1781 fn seeded_acc(id: &str, name: &str, arguments: &str) -> StreamToolCallAccumulator {
1785 let mut acc = StreamToolCallAccumulator::new();
1786 acc.apply_indexed_delta(0, Some(id), Some(name), Some(arguments));
1787 acc
1788 }
1789
1790 #[test]
1794 fn test_take_pending_tool_calls_flushes_then_drains_without_finish_reason() {
1795 let mut acc = seeded_acc("call_1", "read_file", r#"{"path":"Cargo.toml"}"#);
1796
1797 match take_pending_tool_calls(&mut acc, None) {
1798 Some(LlmStreamEvent::ToolCalls(calls)) => {
1799 assert_eq!(calls.len(), 1);
1800 assert_eq!(calls[0].name, "read_file");
1801 assert_eq!(calls[0].arguments, json!({"path": "Cargo.toml"}));
1802 }
1803 other => panic!("expected ToolCalls, got {:?}", other),
1804 }
1805 assert!(acc.is_empty(), "accumulator must be drained after flush");
1806 assert!(take_pending_tool_calls(&mut acc, None).is_none());
1807 }
1808
1809 #[test]
1810 fn test_take_pending_tool_calls_discards_non_tool_finish_reason() {
1811 let mut acc = seeded_acc("call_cut", "read_file", r#"{"path":"#);
1812
1813 assert!(take_pending_tool_calls(&mut acc, Some("length")).is_none());
1814 assert!(
1815 acc.is_empty(),
1816 "discarded unsafe fallback calls must still drain the accumulator"
1817 );
1818 }
1819
1820 #[test]
1821 fn test_take_pending_tool_calls_rejects_malformed_fallback_arguments() {
1822 let mut acc = seeded_acc("call_cut", "read_file", r#"{"path":"#);
1823
1824 assert!(take_pending_tool_calls(&mut acc, None).is_none());
1825 assert!(
1826 acc.is_empty(),
1827 "malformed fallback calls must be drained instead of re-emitted"
1828 );
1829 }
1830
1831 #[test]
1832 fn test_non_tool_finish_reason_leaves_pending_calls_for_done_discard() {
1833 let mut total_tokens = 0u32;
1834 let mut acc = StreamToolCallAccumulator::new();
1835 let mut finish_reason: Option<String> = None;
1836
1837 process_stream_choice(
1838 &choice(
1839 r#"{"delta":{"tool_calls":[{"index":0,"id":"call_cut","function":{"name":"read_file","arguments":"{\"path\":"}}]},"finish_reason":null}"#,
1840 ),
1841 &mut total_tokens,
1842 &mut acc,
1843 &mut finish_reason,
1844 );
1845
1846 let e = process_stream_choice(
1847 &choice(r#"{"delta":{},"finish_reason":"length"}"#),
1848 &mut total_tokens,
1849 &mut acc,
1850 &mut finish_reason,
1851 );
1852
1853 assert!(matches!(e, LlmStreamEvent::TextDelta(s) if s.is_empty()));
1854 assert_eq!(finish_reason.as_deref(), Some("length"));
1855 assert!(take_pending_tool_calls(&mut acc, finish_reason.as_deref()).is_none());
1856 assert!(acc.is_empty());
1857 }
1858
1859 #[test]
1860 fn drop_orphaned_tool_messages_removes_unmatched_tool_results() {
1861 use crate::driver_registry::LlmMessageContent;
1862
1863 let messages = vec![
1864 LlmMessage::text(LlmMessageRole::User, "hello"),
1865 LlmMessage {
1866 role: LlmMessageRole::Tool,
1867 content: LlmMessageContent::Text("result".to_string()),
1868 tool_calls: None,
1869 tool_call_id: Some("call_trimmed".to_string()),
1870 phase: None,
1871 thinking: None,
1872 thinking_signature: None,
1873 },
1874 ];
1875 let filtered = drop_orphaned_tool_messages(&messages);
1876 assert_eq!(filtered.len(), 1);
1877 assert_eq!(filtered[0].role, LlmMessageRole::User);
1878 }
1879
1880 #[test]
1881 fn drop_orphaned_tool_messages_keeps_matched_tool_results() {
1882 use crate::driver_registry::LlmMessageContent;
1883 use crate::tool_types::ToolCall;
1884
1885 let messages = vec![
1886 LlmMessage {
1887 role: LlmMessageRole::Assistant,
1888 content: LlmMessageContent::Text(String::new()),
1889 tool_calls: Some(vec![ToolCall {
1890 id: "call_1".to_string(),
1891 name: "read_file".to_string(),
1892 arguments: json!({}),
1893 }]),
1894 tool_call_id: None,
1895 phase: None,
1896 thinking: None,
1897 thinking_signature: None,
1898 },
1899 LlmMessage {
1900 role: LlmMessageRole::Tool,
1901 content: LlmMessageContent::Text("file content".to_string()),
1902 tool_calls: None,
1903 tool_call_id: Some("call_1".to_string()),
1904 phase: None,
1905 thinking: None,
1906 thinking_signature: None,
1907 },
1908 ];
1909 let filtered = drop_orphaned_tool_messages(&messages);
1910 assert_eq!(filtered.len(), 2);
1911 }
1912}