1use super::http::{default_http_client, normalize_base_url, HttpClient};
4use super::structured;
5use super::types::*;
6use super::LlmClient;
7use crate::llm::types::{ToolResultContent, ToolResultContentField};
8use crate::retry::{AttemptOutcome, RetryConfig};
9use anyhow::{Context, Result};
10use async_trait::async_trait;
11use futures::StreamExt;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Instant;
16use tokio::sync::mpsc;
17
18pub struct OpenAiClient {
20 pub(crate) provider_name: String,
21 pub(crate) api_key: SecretString,
22 pub(crate) model: String,
23 pub(crate) base_url: String,
24 pub(crate) chat_completions_path: String,
25 pub(crate) headers: HashMap<String, String>,
26 pub(crate) temperature: Option<f32>,
27 pub(crate) max_tokens: Option<usize>,
28 pub(crate) logprobs: bool,
29 pub(crate) top_logprobs: Option<usize>,
30 pub(crate) http: Arc<dyn HttpClient>,
31 pub(crate) retry_config: RetryConfig,
32}
33
34impl OpenAiClient {
35 pub(crate) fn parse_tool_arguments(tool_name: &str, arguments: &str) -> serde_json::Value {
36 if arguments.trim().is_empty() {
37 return serde_json::Value::Object(Default::default());
38 }
39
40 serde_json::from_str(arguments).unwrap_or_else(|e| {
41 tracing::warn!(
42 "Failed to parse tool arguments JSON for tool '{}': {}",
43 tool_name,
44 e
45 );
46 serde_json::json!({
47 "__parse_error": format!(
48 "Malformed tool arguments: {}. Raw input: {}",
49 e, arguments
50 )
51 })
52 })
53 }
54
55 fn merge_stream_text(text_content: &mut String, incoming: &str) -> Option<String> {
56 if incoming.is_empty() {
57 return None;
58 }
59 if text_content.is_empty() {
60 text_content.push_str(incoming);
61 return Some(incoming.to_string());
62 }
63 if incoming == text_content.as_str() || text_content.ends_with(incoming) {
64 return None;
65 }
66 if incoming.starts_with(text_content.as_str()) && incoming.len() > text_content.len() {
69 let suffix = &incoming[text_content.len()..];
70 if !suffix.is_empty() {
71 *text_content = incoming.to_string();
72 return Some(suffix.to_string());
73 }
74 return None;
75 }
76 if let Some(suffix) = incoming.strip_prefix(text_content.as_str()) {
77 if suffix.is_empty() {
78 return None;
79 }
80 text_content.push_str(suffix);
81 return Some(suffix.to_string());
82 }
83 text_content.push_str(incoming);
84 Some(incoming.to_string())
85 }
86
87 pub fn new(api_key: String, model: String) -> Self {
88 Self {
89 provider_name: "openai".to_string(),
90 api_key: SecretString::new(api_key),
91 model,
92 base_url: "https://api.openai.com".to_string(),
93 chat_completions_path: "/v1/chat/completions".to_string(),
94 headers: HashMap::new(),
95 temperature: None,
96 max_tokens: None,
97 logprobs: false,
98 top_logprobs: None,
99 http: default_http_client(),
100 retry_config: RetryConfig::default(),
101 }
102 }
103
104 pub fn with_base_url(mut self, base_url: String) -> Self {
105 self.base_url = normalize_base_url(&base_url);
106 self
107 }
108
109 pub fn with_provider_name(mut self, provider_name: impl Into<String>) -> Self {
110 self.provider_name = provider_name.into();
111 self
112 }
113
114 pub fn with_chat_completions_path(mut self, path: impl Into<String>) -> Self {
115 let path = path.into();
116 self.chat_completions_path = if path.starts_with('/') {
117 path
118 } else {
119 format!("/{}", path)
120 };
121 self
122 }
123
124 pub fn with_temperature(mut self, temperature: f32) -> Self {
125 self.temperature = Some(temperature);
126 self
127 }
128
129 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
130 self.headers = headers;
131 self
132 }
133
134 pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
135 self.max_tokens = Some(max_tokens);
136 self
137 }
138
139 pub fn with_logprobs(mut self, enabled: bool) -> Self {
140 self.logprobs = enabled;
141 self
142 }
143
144 pub fn with_top_logprobs(mut self, top_logprobs: usize) -> Self {
145 self.logprobs = true;
146 self.top_logprobs = Some(top_logprobs);
147 self
148 }
149
150 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
151 self.retry_config = retry_config;
152 self
153 }
154
155 pub fn with_http_client(mut self, http: Arc<dyn HttpClient>) -> Self {
156 self.http = http;
157 self
158 }
159
160 pub(crate) fn request_headers(&self) -> Vec<(String, String)> {
161 let mut headers = Vec::with_capacity(self.headers.len() + 1);
162 let has_authorization = self
163 .headers
164 .keys()
165 .any(|key| key.eq_ignore_ascii_case("authorization"));
166 if !has_authorization {
167 headers.push((
168 "Authorization".to_string(),
169 format!("Bearer {}", self.api_key.expose()),
170 ));
171 }
172 headers.extend(
173 self.headers
174 .iter()
175 .map(|(key, value)| (key.clone(), value.clone())),
176 );
177 headers
178 }
179
180 pub(crate) fn convert_messages(&self, messages: &[Message]) -> Vec<serde_json::Value> {
181 messages
182 .iter()
183 .map(|msg| {
184 let content: serde_json::Value = if msg.content.len() == 1 {
185 match &msg.content[0] {
186 ContentBlock::Text { text } => serde_json::json!(text),
187 ContentBlock::ToolResult {
188 tool_use_id,
189 content,
190 ..
191 } => {
192 let content_str = match content {
193 ToolResultContentField::Text(s) => s.clone(),
194 ToolResultContentField::Blocks(blocks) => blocks
195 .iter()
196 .filter_map(|b| {
197 if let ToolResultContent::Text { text } = b {
198 Some(text.clone())
199 } else {
200 None
201 }
202 })
203 .collect::<Vec<_>>()
204 .join("\n"),
205 };
206 return serde_json::json!({
207 "role": "tool",
208 "tool_call_id": tool_use_id,
209 "content": content_str,
210 });
211 }
212 _ => serde_json::json!(""),
213 }
214 } else {
215 serde_json::json!(msg
216 .content
217 .iter()
218 .map(|block| {
219 match block {
220 ContentBlock::Text { text } => serde_json::json!({
221 "type": "text",
222 "text": text,
223 }),
224 ContentBlock::Image { source } => serde_json::json!({
225 "type": "image_url",
226 "image_url": {
227 "url": format!(
228 "data:{};base64,{}",
229 source.media_type, source.data
230 ),
231 }
232 }),
233 ContentBlock::ToolUse { id, name, input } => serde_json::json!({
234 "type": "function",
235 "id": id,
236 "function": {
237 "name": name,
238 "arguments": input.to_string(),
239 }
240 }),
241 _ => serde_json::json!({}),
242 }
243 })
244 .collect::<Vec<_>>())
245 };
246
247 if msg.role == "assistant" {
250 let rc = msg.reasoning_content.as_deref().unwrap_or("");
251 let tool_calls: Vec<_> = msg.tool_calls();
252 if !tool_calls.is_empty() {
253 return serde_json::json!({
254 "role": "assistant",
255 "content": msg.text(),
256 "reasoning_content": rc,
257 "tool_calls": tool_calls.iter().map(|tc| {
258 serde_json::json!({
259 "id": tc.id,
260 "type": "function",
261 "function": {
262 "name": tc.name,
263 "arguments": tc.args.to_string(),
264 }
265 })
266 }).collect::<Vec<_>>(),
267 });
268 }
269 return serde_json::json!({
270 "role": "assistant",
271 "content": content,
272 "reasoning_content": rc,
273 });
274 }
275
276 serde_json::json!({
277 "role": msg.role,
278 "content": content,
279 })
280 })
281 .collect()
282 }
283
284 pub(crate) fn convert_tools(&self, tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
285 tools
286 .iter()
287 .map(|t| {
288 serde_json::json!({
289 "type": "function",
290 "function": {
291 "name": t.name,
292 "description": t.description,
293 "parameters": t.parameters,
294 }
295 })
296 })
297 .collect()
298 }
299}
300
301impl OpenAiClient {
302 fn apply_directive(
307 request: &mut serde_json::Value,
308 directive: &structured::StructuredDirective,
309 ) {
310 if let Some(tool) = &directive.force_tool {
311 request["tool_choice"] = serde_json::json!({
312 "type": "function",
313 "function": { "name": tool }
314 });
315 }
316 if let Some(rf) = &directive.response_format {
317 request["response_format"] = match rf {
318 structured::ResponseFormat::JsonObject => {
319 serde_json::json!({ "type": "json_object" })
320 }
321 structured::ResponseFormat::JsonSchema { name, schema } => serde_json::json!({
322 "type": "json_schema",
323 "json_schema": { "name": name, "schema": schema, "strict": true }
324 }),
325 };
326 }
327 }
328
329 fn build_chat_request(
331 &self,
332 messages: &[Message],
333 system: Option<&str>,
334 tools: &[ToolDefinition],
335 directive: Option<&structured::StructuredDirective>,
336 ) -> serde_json::Value {
337 let mut openai_messages = Vec::new();
338
339 if let Some(sys) = system {
340 openai_messages.push(serde_json::json!({
341 "role": "system",
342 "content": sys,
343 }));
344 }
345
346 openai_messages.extend(self.convert_messages(messages));
347
348 let mut request = serde_json::json!({
349 "model": self.model,
350 "messages": openai_messages,
351 });
352
353 if let Some(temp) = self.temperature {
354 request["temperature"] = serde_json::json!(temp);
355 }
356 if let Some(max) = self.max_tokens {
357 request["max_tokens"] = serde_json::json!(max);
358 }
359 if self.logprobs {
360 request["logprobs"] = serde_json::json!(true);
361 if let Some(top_logprobs) = self.top_logprobs {
362 request["top_logprobs"] = serde_json::json!(top_logprobs);
363 }
364 }
365
366 if !tools.is_empty() {
367 request["tools"] = serde_json::json!(self.convert_tools(tools));
368 }
369
370 if let Some(directive) = directive {
371 Self::apply_directive(&mut request, directive);
372 }
373
374 request
375 }
376
377 async fn send_request(&self, request: serde_json::Value) -> Result<LlmResponse> {
379 {
380 let request_started_at = Instant::now();
381 let url = format!("{}{}", self.base_url, self.chat_completions_path);
382 let request_headers = self.request_headers();
383
384 let response = crate::retry::with_retry(&self.retry_config, |_attempt| {
385 let http = &self.http;
386 let url = &url;
387 let request_headers = request_headers.clone();
388 let request = &request;
389 async move {
390 let headers = request_headers
391 .iter()
392 .map(|(key, value)| (key.as_str(), value.as_str()))
393 .collect::<Vec<_>>();
394 let cancel_token = tokio_util::sync::CancellationToken::new();
396 match http.post(url, headers, request, cancel_token).await {
397 Ok(resp) => {
398 let status = reqwest::StatusCode::from_u16(resp.status)
399 .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
400 if status.is_success() {
401 AttemptOutcome::Success(resp.body)
402 } else if self.retry_config.is_retryable_status(status) {
403 AttemptOutcome::Retryable {
404 status,
405 body: resp.body,
406 retry_after: None,
407 }
408 } else {
409 AttemptOutcome::Fatal(anyhow::anyhow!(
410 "OpenAI API error at {} ({}): {}",
411 url,
412 status,
413 resp.body
414 ))
415 }
416 }
417 Err(e) => {
418 tracing::error!("HTTP error: {e:?}");
419 AttemptOutcome::Fatal(e)
420 }
421 }
422 }
423 })
424 .await?;
425
426 let parsed: OpenAiResponse =
427 serde_json::from_str(&response).context("Failed to parse OpenAI response")?;
428
429 let choice = parsed.choices.into_iter().next().context("No choices")?;
430 let token_logprobs = choice
431 .logprobs
432 .as_ref()
433 .map(openai_logprobs_to_token_logprobs)
434 .unwrap_or_default();
435
436 let mut content = vec![];
437
438 let reasoning_content = choice.message.reasoning_content;
439
440 let text_content = choice.message.content;
441
442 if let Some(text) = text_content {
443 if !text.is_empty() {
444 content.push(ContentBlock::Text { text });
445 }
446 }
447
448 if let Some(tool_calls) = choice.message.tool_calls {
449 for tc in tool_calls {
450 content.push(ContentBlock::ToolUse {
451 id: tc.id,
452 name: tc.function.name.clone(),
453 input: Self::parse_tool_arguments(
454 &tc.function.name,
455 &tc.function.arguments,
456 ),
457 });
458 }
459 }
460
461 let llm_response = LlmResponse {
462 message: Message {
463 role: "assistant".to_string(),
464 content,
465 reasoning_content,
466 },
467 usage: TokenUsage {
468 prompt_tokens: parsed.usage.prompt_tokens,
469 completion_tokens: parsed.usage.completion_tokens,
470 total_tokens: {
471 let t = parsed.usage.total_tokens;
472 if t == 0 {
474 parsed.usage.total_characters.unwrap_or(0)
475 } else {
476 t
477 }
478 },
479 cache_read_tokens: parsed
480 .usage
481 .prompt_tokens_details
482 .as_ref()
483 .and_then(|d| d.cached_tokens),
484 cache_write_tokens: None,
485 },
486 stop_reason: choice.finish_reason,
487 token_logprobs,
488 meta: Some(LlmResponseMeta {
489 provider: Some(self.provider_name.clone()),
490 request_model: Some(self.model.clone()),
491 request_url: Some(url.clone()),
492 response_id: parsed.id,
493 response_model: parsed.model,
494 response_object: parsed.object,
495 first_token_ms: None,
496 duration_ms: Some(request_started_at.elapsed().as_millis() as u64),
497 }),
498 };
499
500 crate::telemetry::record_llm_usage(
501 llm_response.usage.prompt_tokens,
502 llm_response.usage.completion_tokens,
503 llm_response.usage.total_tokens,
504 llm_response.stop_reason.as_deref(),
505 );
506
507 Ok(llm_response)
508 }
509 }
510}
511
512#[async_trait]
513impl LlmClient for OpenAiClient {
514 async fn complete(
515 &self,
516 messages: &[Message],
517 system: Option<&str>,
518 tools: &[ToolDefinition],
519 ) -> Result<LlmResponse> {
520 self.send_request(self.build_chat_request(messages, system, tools, None))
521 .await
522 }
523
524 async fn complete_structured(
525 &self,
526 messages: &[Message],
527 system: Option<&str>,
528 tools: &[ToolDefinition],
529 directive: &structured::StructuredDirective,
530 ) -> Result<LlmResponse> {
531 self.send_request(self.build_chat_request(messages, system, tools, Some(directive)))
532 .await
533 }
534
535 fn native_structured_support(&self) -> structured::NativeStructuredSupport {
536 structured::NativeStructuredSupport::JsonSchema
537 }
538
539 async fn complete_streaming(
540 &self,
541 messages: &[Message],
542 system: Option<&str>,
543 tools: &[ToolDefinition],
544 cancel_token: tokio_util::sync::CancellationToken,
545 ) -> Result<mpsc::Receiver<StreamEvent>> {
546 self.send_streaming(
547 self.build_chat_request(messages, system, tools, None),
548 cancel_token,
549 )
550 .await
551 }
552
553 async fn complete_streaming_structured(
554 &self,
555 messages: &[Message],
556 system: Option<&str>,
557 tools: &[ToolDefinition],
558 directive: &structured::StructuredDirective,
559 cancel_token: tokio_util::sync::CancellationToken,
560 ) -> Result<mpsc::Receiver<StreamEvent>> {
561 self.send_streaming(
562 self.build_chat_request(messages, system, tools, Some(directive)),
563 cancel_token,
564 )
565 .await
566 }
567}
568
569impl OpenAiClient {
570 async fn send_streaming(
572 &self,
573 mut request: serde_json::Value,
574 cancel_token: tokio_util::sync::CancellationToken,
575 ) -> Result<mpsc::Receiver<StreamEvent>> {
576 {
577 request["stream"] = serde_json::json!(true);
578 request["stream_options"] = serde_json::json!({ "include_usage": true });
579 let request_started_at = Instant::now();
580 let url = format!("{}{}", self.base_url, self.chat_completions_path);
581 let request_headers = self.request_headers();
582
583 let streaming_resp = crate::retry::with_retry(&self.retry_config, |_attempt| {
584 let http = &self.http;
585 let url = &url;
586 let request_headers = request_headers.clone();
587 let request = &request;
588 let cancel_token = cancel_token.clone();
589 async move {
590 let headers = request_headers
591 .iter()
592 .map(|(key, value)| (key.as_str(), value.as_str()))
593 .collect::<Vec<_>>();
594 let resp = tokio::select! {
596 _ = cancel_token.cancelled() => {
597 return AttemptOutcome::Fatal(anyhow::anyhow!("HTTP request cancelled"));
598 }
599 result = http.post_streaming(url, headers, request, cancel_token.clone()) => {
600 match result {
601 Ok(r) => r,
602 Err(e) => {
603 return if crate::retry::is_transient_error(&e) {
609 AttemptOutcome::Retryable {
610 status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
611 body: format!("network error: {e}"),
612 retry_after: None,
613 }
614 } else {
615 AttemptOutcome::Fatal(anyhow::anyhow!(
616 "HTTP request failed: {}",
617 e
618 ))
619 };
620 }
621 }
622 }
623 };
624 let status = reqwest::StatusCode::from_u16(resp.status)
625 .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
626 if status.is_success() {
627 AttemptOutcome::Success(resp)
628 } else {
629 let retry_after = resp
630 .retry_after
631 .as_deref()
632 .and_then(|v| RetryConfig::parse_retry_after(Some(v)));
633 if self.retry_config.is_retryable_status(status) {
634 AttemptOutcome::Retryable {
635 status,
636 body: resp.error_body,
637 retry_after,
638 }
639 } else {
640 AttemptOutcome::Fatal(anyhow::anyhow!(
641 "OpenAI API error at {} ({}): {}",
642 url,
643 status,
644 resp.error_body
645 ))
646 }
647 }
648 }
649 })
650 .await?;
651
652 let (tx, rx) = mpsc::channel(100);
653
654 let mut stream = streaming_resp.byte_stream;
655 let provider_name = self.provider_name.clone();
656 let request_model = self.model.clone();
657 let request_url = url.clone();
658 tokio::spawn(async move {
659 let mut buffer = String::new();
660 let mut content_blocks: Vec<ContentBlock> = Vec::new();
661 let mut text_content = String::new();
662 let mut reasoning_content_accum = String::new();
663 let mut tool_calls: std::collections::BTreeMap<usize, (String, String, String)> =
664 std::collections::BTreeMap::new();
665 let mut usage = TokenUsage::default();
666 let mut finish_reason = None;
667 let mut token_logprobs: Vec<TokenLogProb> = Vec::new();
668 let mut response_id = None;
669 let mut response_model = None;
670 let mut response_object = None;
671 let mut first_token_ms = None;
672 let mut saw_done = false;
673 let mut parsed_any_event = false;
674
675 while let Some(chunk_result) = stream.next().await {
676 let chunk = match chunk_result {
677 Ok(c) => c,
678 Err(e) => {
679 tracing::error!("Stream error: {}", e);
680 break;
681 }
682 };
683
684 buffer.push_str(&String::from_utf8_lossy(&chunk));
685
686 while let Some(event_end) = buffer.find("\n\n") {
687 let event_data: String = buffer.drain(..event_end).collect();
688 buffer.drain(..2);
689
690 for line in event_data.lines() {
691 if let Some(data) = line.strip_prefix("data: ") {
692 if data == "[DONE]" {
693 saw_done = true;
694 if !text_content.is_empty() {
695 content_blocks.push(ContentBlock::Text {
696 text: text_content.clone(),
697 });
698 }
699 for (_, (id, name, args)) in tool_calls.iter() {
700 content_blocks.push(ContentBlock::ToolUse {
701 id: id.clone(),
702 name: name.clone(),
703 input: Self::parse_tool_arguments(name, args),
704 });
705 }
706 tool_calls.clear();
707 crate::telemetry::record_llm_usage(
708 usage.prompt_tokens,
709 usage.completion_tokens,
710 usage.total_tokens,
711 finish_reason.as_deref(),
712 );
713 let response = LlmResponse {
714 message: Message {
715 role: "assistant".to_string(),
716 content: std::mem::take(&mut content_blocks),
717 reasoning_content: if reasoning_content_accum.is_empty()
718 {
719 None
720 } else {
721 Some(std::mem::take(&mut reasoning_content_accum))
722 },
723 },
724 usage: usage.clone(),
725 stop_reason: std::mem::take(&mut finish_reason),
726 token_logprobs: std::mem::take(&mut token_logprobs),
727 meta: Some(LlmResponseMeta {
728 provider: Some(provider_name.clone()),
729 request_model: Some(request_model.clone()),
730 request_url: Some(request_url.clone()),
731 response_id: response_id.clone(),
732 response_model: response_model.clone(),
733 response_object: response_object.clone(),
734 first_token_ms,
735 duration_ms: Some(
736 request_started_at.elapsed().as_millis() as u64,
737 ),
738 }),
739 };
740 let _ = tx.send(StreamEvent::Done(response)).await;
741 continue;
742 }
743
744 if let Ok(event) = serde_json::from_str::<OpenAiStreamChunk>(data) {
745 parsed_any_event = true;
746 if response_id.is_none() {
747 response_id = event.id.clone();
748 }
749 if response_model.is_none() {
750 response_model = event.model.clone();
751 }
752 if response_object.is_none() {
753 response_object = event.object.clone();
754 }
755 if let Some(u) = event.usage {
756 usage.prompt_tokens = u.prompt_tokens;
757 usage.completion_tokens = u.completion_tokens;
758 usage.total_tokens = u.total_tokens;
759 if usage.total_tokens == 0 {
761 usage.total_tokens = u.total_characters.unwrap_or(0);
762 }
763 usage.cache_read_tokens = u
764 .prompt_tokens_details
765 .as_ref()
766 .and_then(|d| d.cached_tokens);
767 }
768
769 if let Some(choice) = event.choices.into_iter().next() {
770 if let Some(logprobs) = choice.logprobs.as_ref() {
771 token_logprobs.extend(
772 openai_logprobs_to_token_logprobs(logprobs),
773 );
774 }
775 if let Some(reason) = choice.finish_reason {
776 finish_reason = Some(reason);
777 }
778
779 if let Some(message) = choice.message {
780 let skip_content = !text_content.is_empty();
783 if let Some(reasoning) = message.reasoning_content {
784 if first_token_ms.is_none() {
794 first_token_ms = Some(
795 request_started_at.elapsed().as_millis()
796 as u64,
797 );
798 }
799 if let Some(delta) = Self::merge_stream_text(
800 &mut reasoning_content_accum,
801 &reasoning,
802 ) {
803 let _ = tx
804 .send(StreamEvent::ReasoningDelta(delta))
805 .await;
806 }
807 }
808 if !skip_content {
809 if let Some(content) = message
810 .content
811 .filter(|value| !value.is_empty())
812 {
813 if first_token_ms.is_none() {
814 first_token_ms = Some(
815 request_started_at.elapsed().as_millis()
816 as u64,
817 );
818 }
819 if let Some(delta) = Self::merge_stream_text(
820 &mut text_content,
821 &content,
822 ) {
823 let _ = tx
824 .send(StreamEvent::TextDelta(delta))
825 .await;
826 }
827 }
828 }
829 if let Some(tcs) = message.tool_calls {
830 for (index, tc) in tcs.into_iter().enumerate() {
831 tool_calls.insert(
832 index,
833 (
834 tc.id,
835 tc.function.name,
836 tc.function.arguments,
837 ),
838 );
839 }
840 }
841 } else if let Some(delta) = choice.delta {
842 if let Some(ref rc) = delta.reasoning_content {
843 if first_token_ms.is_none() {
846 first_token_ms = Some(
847 request_started_at.elapsed().as_millis()
848 as u64,
849 );
850 }
851 if let Some(delta) = Self::merge_stream_text(
852 &mut reasoning_content_accum,
853 rc,
854 ) {
855 let _ = tx
856 .send(StreamEvent::ReasoningDelta(delta))
857 .await;
858 }
859 }
860
861 if let Some(content) = delta.content {
862 if first_token_ms.is_none() {
863 first_token_ms = Some(
864 request_started_at.elapsed().as_millis()
865 as u64,
866 );
867 }
868 if let Some(delta) = Self::merge_stream_text(
869 &mut text_content,
870 &content,
871 ) {
872 let _ = tx
873 .send(StreamEvent::TextDelta(delta))
874 .await;
875 }
876 }
877
878 if let Some(tcs) = delta.tool_calls {
879 for tc in tcs {
880 let entry = tool_calls
881 .entry(tc.index)
882 .or_insert_with(|| {
883 (
884 String::new(),
885 String::new(),
886 String::new(),
887 )
888 });
889
890 if let Some(id) = tc.id {
891 entry.0 = id;
892 }
893 if let Some(func) = tc.function {
894 if let Some(name) = func.name {
895 if first_token_ms.is_none() {
896 first_token_ms = Some(
897 request_started_at
898 .elapsed()
899 .as_millis()
900 as u64,
901 );
902 }
903 entry.1 = name.clone();
904 let _ = tx
905 .send(StreamEvent::ToolUseStart {
906 id: entry.0.clone(),
907 name,
908 })
909 .await;
910 }
911 if let Some(args) = func.arguments {
912 entry.2.push_str(&args);
913 let _ = tx
914 .send(
915 StreamEvent::ToolUseInputDelta(
916 args,
917 ),
918 )
919 .await;
920 }
921 }
922 }
923 }
924 }
925 }
926 }
927 }
928 }
929 }
930 }
931
932 if saw_done {
933 return;
934 }
935
936 let trailing = buffer.trim();
937 if !trailing.is_empty() {
938 if let Ok(event) = serde_json::from_str::<OpenAiStreamChunk>(trailing) {
939 parsed_any_event = true;
940 if response_id.is_none() {
941 response_id = event.id.clone();
942 }
943 if response_model.is_none() {
944 response_model = event.model.clone();
945 }
946 if response_object.is_none() {
947 response_object = event.object.clone();
948 }
949 if let Some(u) = event.usage {
950 usage.prompt_tokens = u.prompt_tokens;
951 usage.completion_tokens = u.completion_tokens;
952 usage.total_tokens = u.total_tokens;
953 usage.cache_read_tokens = u
954 .prompt_tokens_details
955 .as_ref()
956 .and_then(|d| d.cached_tokens);
957 }
958 if let Some(choice) = event.choices.into_iter().next() {
959 if let Some(logprobs) = choice.logprobs.as_ref() {
960 token_logprobs.extend(openai_logprobs_to_token_logprobs(logprobs));
961 }
962 if let Some(reason) = choice.finish_reason {
963 finish_reason = Some(reason);
964 }
965 let skip_content = !text_content.is_empty();
968 if let Some(message) = choice.message {
969 if let Some(reasoning) = message.reasoning_content {
970 if first_token_ms.is_none() {
973 first_token_ms =
974 Some(request_started_at.elapsed().as_millis() as u64);
975 }
976 if let Some(delta) = Self::merge_stream_text(
977 &mut reasoning_content_accum,
978 &reasoning,
979 ) {
980 let _ = tx.send(StreamEvent::ReasoningDelta(delta)).await;
981 }
982 }
983 if !skip_content {
984 if let Some(content) =
985 message.content.filter(|value| !value.is_empty())
986 {
987 if first_token_ms.is_none() {
988 first_token_ms = Some(
989 request_started_at.elapsed().as_millis() as u64,
990 );
991 }
992 if let Some(delta) =
993 Self::merge_stream_text(&mut text_content, &content)
994 {
995 let _ = tx.send(StreamEvent::TextDelta(delta)).await;
996 }
997 }
998 }
999 if let Some(tcs) = message.tool_calls {
1000 for (index, tc) in tcs.into_iter().enumerate() {
1001 tool_calls.insert(
1002 index,
1003 (tc.id, tc.function.name, tc.function.arguments),
1004 );
1005 }
1006 }
1007 } else if let Some(delta) = choice.delta {
1008 if let Some(ref rc) = delta.reasoning_content {
1009 if first_token_ms.is_none() {
1012 first_token_ms =
1013 Some(request_started_at.elapsed().as_millis() as u64);
1014 }
1015 if let Some(delta) =
1016 Self::merge_stream_text(&mut reasoning_content_accum, rc)
1017 {
1018 let _ = tx.send(StreamEvent::ReasoningDelta(delta)).await;
1019 }
1020 }
1021 if let Some(content) = delta.content {
1022 if first_token_ms.is_none() {
1023 first_token_ms =
1024 Some(request_started_at.elapsed().as_millis() as u64);
1025 }
1026 if let Some(delta) =
1027 Self::merge_stream_text(&mut text_content, &content)
1028 {
1029 let _ = tx.send(StreamEvent::TextDelta(delta)).await;
1030 }
1031 }
1032 }
1033 }
1034 } else if let Ok(response) = serde_json::from_str::<OpenAiResponse>(trailing) {
1035 parsed_any_event = true;
1036 response_id = response.id.clone();
1037 response_model = response.model.clone();
1038 response_object = response.object.clone();
1039 usage.prompt_tokens = response.usage.prompt_tokens;
1040 usage.completion_tokens = response.usage.completion_tokens;
1041 usage.total_tokens = response.usage.total_tokens;
1042 if usage.total_tokens == 0 {
1044 usage.total_tokens = response.usage.total_characters.unwrap_or(0);
1045 }
1046 usage.cache_read_tokens = response
1047 .usage
1048 .prompt_tokens_details
1049 .as_ref()
1050 .and_then(|d| d.cached_tokens);
1051
1052 if let Some(choice) = response.choices.into_iter().next() {
1053 if let Some(logprobs) = choice.logprobs.as_ref() {
1054 token_logprobs.extend(openai_logprobs_to_token_logprobs(logprobs));
1055 }
1056 finish_reason = choice.finish_reason;
1057 if let Some(text) =
1058 choice.message.content.filter(|text| !text.is_empty())
1059 {
1060 if first_token_ms.is_none() {
1061 first_token_ms =
1062 Some(request_started_at.elapsed().as_millis() as u64);
1063 }
1064 let _ = Self::merge_stream_text(&mut text_content, &text);
1065 }
1066 if let Some(reasoning) = choice.message.reasoning_content {
1067 reasoning_content_accum.push_str(&reasoning);
1068 }
1069 if let Some(final_tool_calls) = choice.message.tool_calls {
1070 for tc in final_tool_calls {
1071 tool_calls.insert(
1072 tool_calls.len(),
1073 (tc.id, tc.function.name, tc.function.arguments),
1074 );
1075 }
1076 }
1077 }
1078 }
1079 }
1080
1081 if parsed_any_event
1082 || !text_content.is_empty()
1083 || !tool_calls.is_empty()
1084 || !content_blocks.is_empty()
1085 {
1086 tracing::warn!(
1087 provider = %provider_name,
1088 model = %request_model,
1089 "OpenAI-compatible stream ended without [DONE]; finalizing buffered response"
1090 );
1091 if !text_content.is_empty() {
1092 content_blocks.push(ContentBlock::Text {
1093 text: text_content.clone(),
1094 });
1095 }
1096 for (_, (id, name, args)) in tool_calls.iter() {
1097 content_blocks.push(ContentBlock::ToolUse {
1098 id: id.clone(),
1099 name: name.clone(),
1100 input: Self::parse_tool_arguments(name, args),
1101 });
1102 }
1103 tool_calls.clear();
1104 crate::telemetry::record_llm_usage(
1105 usage.prompt_tokens,
1106 usage.completion_tokens,
1107 usage.total_tokens,
1108 finish_reason.as_deref(),
1109 );
1110 let response = LlmResponse {
1111 message: Message {
1112 role: "assistant".to_string(),
1113 content: std::mem::take(&mut content_blocks),
1114 reasoning_content: if reasoning_content_accum.is_empty() {
1115 None
1116 } else {
1117 Some(std::mem::take(&mut reasoning_content_accum))
1118 },
1119 },
1120 usage: usage.clone(),
1121 stop_reason: std::mem::take(&mut finish_reason),
1122 token_logprobs: std::mem::take(&mut token_logprobs),
1123 meta: Some(LlmResponseMeta {
1124 provider: Some(provider_name.clone()),
1125 request_model: Some(request_model.clone()),
1126 request_url: Some(request_url.clone()),
1127 response_id: response_id.clone(),
1128 response_model: response_model.clone(),
1129 response_object: response_object.clone(),
1130 first_token_ms,
1131 duration_ms: Some(request_started_at.elapsed().as_millis() as u64),
1132 }),
1133 };
1134 let _ = tx.send(StreamEvent::Done(response)).await;
1135 } else {
1136 tracing::warn!(
1137 provider = %provider_name,
1138 model = %request_model,
1139 trailing = %trailing.chars().take(400).collect::<String>(),
1140 "OpenAI-compatible stream ended without any parseable events"
1141 );
1142 }
1143 });
1144
1145 Ok(rx)
1146 }
1147 }
1148}
1149
1150fn openai_logprobs_to_token_logprobs(logprobs: &OpenAiChoiceLogprobs) -> Vec<TokenLogProb> {
1151 logprobs
1152 .content
1153 .as_ref()
1154 .map(|items| {
1155 items
1156 .iter()
1157 .map(|item| TokenLogProb {
1158 token: item.token.clone(),
1159 logprob: item.logprob,
1160 bytes: item.bytes.clone(),
1161 top_logprobs: item
1162 .top_logprobs
1163 .iter()
1164 .map(|top| TopTokenLogProb {
1165 token: top.token.clone(),
1166 logprob: top.logprob,
1167 bytes: top.bytes.clone(),
1168 })
1169 .collect(),
1170 })
1171 .collect()
1172 })
1173 .unwrap_or_default()
1174}
1175
1176#[derive(Debug, Deserialize)]
1178pub(crate) struct OpenAiResponse {
1179 #[serde(default)]
1180 pub(crate) id: Option<String>,
1181 #[serde(default)]
1182 pub(crate) object: Option<String>,
1183 #[serde(default)]
1184 pub(crate) model: Option<String>,
1185 pub(crate) choices: Vec<OpenAiChoice>,
1186 pub(crate) usage: OpenAiUsage,
1187}
1188
1189#[derive(Debug, Deserialize)]
1190pub(crate) struct OpenAiChoice {
1191 pub(crate) message: OpenAiMessage,
1192 pub(crate) finish_reason: Option<String>,
1193 #[serde(default)]
1194 pub(crate) logprobs: Option<OpenAiChoiceLogprobs>,
1195}
1196
1197#[derive(Debug, Deserialize)]
1198pub(crate) struct OpenAiChoiceLogprobs {
1199 #[serde(default)]
1200 pub(crate) content: Option<Vec<OpenAiTokenLogprob>>,
1201}
1202
1203#[derive(Debug, Deserialize)]
1204pub(crate) struct OpenAiTokenLogprob {
1205 pub(crate) token: String,
1206 pub(crate) logprob: f64,
1207 #[serde(default)]
1208 pub(crate) bytes: Option<Vec<u8>>,
1209 #[serde(default)]
1210 pub(crate) top_logprobs: Vec<OpenAiTopLogprob>,
1211}
1212
1213#[derive(Debug, Deserialize)]
1214pub(crate) struct OpenAiTopLogprob {
1215 pub(crate) token: String,
1216 pub(crate) logprob: f64,
1217 #[serde(default)]
1218 pub(crate) bytes: Option<Vec<u8>>,
1219}
1220
1221#[derive(Debug, Deserialize)]
1222pub(crate) struct OpenAiMessage {
1223 #[serde(alias = "reasoning")]
1228 pub(crate) reasoning_content: Option<String>,
1229 pub(crate) content: Option<String>,
1230 pub(crate) tool_calls: Option<Vec<OpenAiToolCall>>,
1231}
1232
1233#[derive(Debug, Deserialize)]
1234pub(crate) struct OpenAiToolCall {
1235 pub(crate) id: String,
1236 pub(crate) function: OpenAiFunction,
1237}
1238
1239#[derive(Debug, Deserialize)]
1240pub(crate) struct OpenAiFunction {
1241 pub(crate) name: String,
1242 pub(crate) arguments: String,
1243}
1244
1245#[derive(Debug, Deserialize)]
1246pub(crate) struct OpenAiUsage {
1247 #[serde(default)]
1248 pub(crate) prompt_tokens: usize,
1249 #[serde(default)]
1250 pub(crate) completion_tokens: usize,
1251 #[serde(default)]
1252 pub(crate) total_tokens: usize,
1253 #[serde(default)]
1255 pub(crate) total_characters: Option<usize>,
1256 #[serde(default)]
1258 pub(crate) prompt_tokens_details: Option<OpenAiPromptTokensDetails>,
1259}
1260
1261#[derive(Debug, Deserialize)]
1262pub(crate) struct OpenAiPromptTokensDetails {
1263 #[serde(default)]
1264 pub(crate) cached_tokens: Option<usize>,
1265}
1266
1267#[derive(Debug, Deserialize)]
1269pub(crate) struct OpenAiStreamChunk {
1270 #[serde(default)]
1271 pub(crate) id: Option<String>,
1272 #[serde(default)]
1273 pub(crate) object: Option<String>,
1274 #[serde(default)]
1275 pub(crate) model: Option<String>,
1276 pub(crate) choices: Vec<OpenAiStreamChoice>,
1277 pub(crate) usage: Option<OpenAiUsage>,
1278}
1279
1280#[derive(Debug, Deserialize)]
1281pub(crate) struct OpenAiStreamChoice {
1282 pub(crate) message: Option<OpenAiMessage>,
1283 pub(crate) delta: Option<OpenAiDelta>,
1284 pub(crate) finish_reason: Option<String>,
1285 #[serde(default)]
1286 pub(crate) logprobs: Option<OpenAiChoiceLogprobs>,
1287}
1288
1289#[derive(Debug, Deserialize)]
1290pub(crate) struct OpenAiDelta {
1291 #[serde(alias = "reasoning")]
1296 pub(crate) reasoning_content: Option<String>,
1297 pub(crate) content: Option<String>,
1298 pub(crate) tool_calls: Option<Vec<OpenAiToolCallDelta>>,
1299}
1300
1301#[derive(Debug, Deserialize)]
1302pub(crate) struct OpenAiToolCallDelta {
1303 pub(crate) index: usize,
1304 pub(crate) id: Option<String>,
1305 pub(crate) function: Option<OpenAiFunctionDelta>,
1306}
1307
1308#[derive(Debug, Deserialize)]
1309pub(crate) struct OpenAiFunctionDelta {
1310 pub(crate) name: Option<String>,
1311 pub(crate) arguments: Option<String>,
1312}
1313
1314#[cfg(test)]
1319mod tests {
1320 use super::*;
1321 use crate::llm::types::{Message, ToolDefinition};
1322
1323 fn make_client() -> OpenAiClient {
1324 OpenAiClient::new("test-key".to_string(), "gpt-test".to_string())
1325 }
1326
1327 struct MockSseHttp {
1334 chunks: Vec<String>,
1335 }
1336
1337 #[async_trait::async_trait]
1338 impl crate::llm::http::HttpClient for MockSseHttp {
1339 async fn post(
1340 &self,
1341 _url: &str,
1342 _headers: Vec<(&str, &str)>,
1343 _body: &serde_json::Value,
1344 _cancel: tokio_util::sync::CancellationToken,
1345 ) -> anyhow::Result<crate::llm::http::HttpResponse> {
1346 anyhow::bail!("post is unused in the streaming test")
1347 }
1348
1349 async fn post_streaming(
1350 &self,
1351 _url: &str,
1352 _headers: Vec<(&str, &str)>,
1353 _body: &serde_json::Value,
1354 _cancel: tokio_util::sync::CancellationToken,
1355 ) -> anyhow::Result<crate::llm::http::StreamingHttpResponse> {
1356 let items: Vec<anyhow::Result<bytes::Bytes>> = self
1357 .chunks
1358 .iter()
1359 .map(|s| Ok(bytes::Bytes::from(s.clone())))
1360 .collect();
1361 Ok(crate::llm::http::StreamingHttpResponse {
1362 status: 200,
1363 retry_after: None,
1364 byte_stream: Box::pin(futures::stream::iter(items)),
1365 error_body: String::new(),
1366 })
1367 }
1368 }
1369
1370 fn glm_client(chunks: Vec<String>) -> OpenAiClient {
1371 OpenAiClient::new("k".to_string(), "glm-test".to_string())
1372 .with_http_client(std::sync::Arc::new(MockSseHttp { chunks }))
1373 }
1374
1375 async fn drain_to_done(client: &OpenAiClient) -> crate::llm::LlmResponse {
1376 use crate::llm::{LlmClient, StreamEvent};
1377 let mut rx = client
1378 .complete_streaming(
1379 &[Message::user("go")],
1380 None,
1381 &[],
1382 tokio_util::sync::CancellationToken::new(),
1383 )
1384 .await
1385 .expect("stream opened");
1386 let mut done = None;
1387 while let Some(ev) = rx.recv().await {
1388 if let StreamEvent::Done(resp) = ev {
1389 done = Some(resp);
1390 }
1391 }
1392 done.expect("a Done event")
1393 }
1394
1395 #[tokio::test]
1396 async fn streaming_reasoning_does_not_leak_into_content_and_keeps_tool_call() {
1397 let chunks = vec![
1398 "data: {\"choices\":[{\"delta\":{\"reasoning\":\"Let me plan the workers\"}}]}\n\n"
1399 .to_string(),
1400 "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"parallel_task\",\"arguments\":\"{}\"}}]}}]}\n\n"
1401 .to_string(),
1402 "data: [DONE]\n\n".to_string(),
1403 ];
1404 let resp = drain_to_done(&glm_client(chunks)).await;
1405 assert_eq!(resp.message.text(), "", "reasoning leaked into content");
1407 assert_eq!(
1408 resp.message.reasoning_content.as_deref(),
1409 Some("Let me plan the workers")
1410 );
1411 let calls = resp.message.tool_calls();
1413 assert_eq!(calls.len(), 1);
1414 assert_eq!(calls[0].name, "parallel_task");
1415 }
1416
1417 #[tokio::test]
1418 async fn streaming_reasoning_only_turn_yields_empty_text() {
1419 let chunks = vec![
1423 "data: {\"choices\":[{\"delta\":{\"reasoning\":\"still thinking, no answer yet\"}}]}\n\n"
1424 .to_string(),
1425 "data: [DONE]\n\n".to_string(),
1426 ];
1427 let resp = drain_to_done(&glm_client(chunks)).await;
1428 assert_eq!(resp.message.text(), "");
1429 assert_eq!(
1430 resp.message.reasoning_content.as_deref(),
1431 Some("still thinking, no answer yet")
1432 );
1433 assert!(resp.message.tool_calls().is_empty());
1434 }
1435
1436 #[tokio::test]
1437 async fn streaming_collects_token_logprobs() {
1438 let chunks = vec![
1439 "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"logprobs\":{\"content\":[{\"token\":\"hello\",\"logprob\":-0.2,\"bytes\":[104,101,108,108,111],\"top_logprobs\":[{\"token\":\"hi\",\"logprob\":-1.2,\"bytes\":[104,105]}]}]}}]}\n\n"
1440 .to_string(),
1441 "data: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n"
1442 .to_string(),
1443 "data: [DONE]\n\n".to_string(),
1444 ];
1445 let resp = drain_to_done(&glm_client(chunks).with_logprobs(true)).await;
1446 assert_eq!(resp.text(), "hello");
1447 assert_eq!(resp.token_logprobs.len(), 1);
1448 assert_eq!(resp.token_logprobs[0].token, "hello");
1449 assert_eq!(resp.token_logprobs[0].logprob, -0.2);
1450 assert_eq!(
1451 resp.token_logprobs[0].bytes.as_deref(),
1452 Some(&[104, 101, 108, 108, 111][..])
1453 );
1454 assert_eq!(resp.token_logprobs[0].top_logprobs[0].token, "hi");
1455 assert_eq!(resp.token_logprobs[0].top_logprobs[0].logprob, -1.2);
1456 }
1457
1458 #[test]
1459 fn test_apply_directive_forced_function_tool_choice() {
1460 let mut req = serde_json::json!({ "model": "m" });
1461 OpenAiClient::apply_directive(
1462 &mut req,
1463 &structured::StructuredDirective {
1464 force_tool: Some("emit_person".to_string()),
1465 response_format: None,
1466 },
1467 );
1468 assert_eq!(req["tool_choice"]["type"], "function");
1469 assert_eq!(req["tool_choice"]["function"]["name"], "emit_person");
1470 assert!(req.get("response_format").is_none());
1471 }
1472
1473 #[test]
1474 fn test_apply_directive_json_schema_strict() {
1475 let mut req = serde_json::json!({});
1476 OpenAiClient::apply_directive(
1477 &mut req,
1478 &structured::StructuredDirective {
1479 force_tool: None,
1480 response_format: Some(structured::ResponseFormat::JsonSchema {
1481 name: "person".to_string(),
1482 schema: serde_json::json!({ "type": "object" }),
1483 }),
1484 },
1485 );
1486 assert_eq!(req["response_format"]["type"], "json_schema");
1487 assert_eq!(req["response_format"]["json_schema"]["name"], "person");
1488 assert_eq!(req["response_format"]["json_schema"]["strict"], true);
1489 assert!(req.get("tool_choice").is_none());
1490 }
1491
1492 #[test]
1493 fn test_apply_directive_json_object() {
1494 let mut req = serde_json::json!({});
1495 OpenAiClient::apply_directive(
1496 &mut req,
1497 &structured::StructuredDirective {
1498 force_tool: None,
1499 response_format: Some(structured::ResponseFormat::JsonObject),
1500 },
1501 );
1502 assert_eq!(req["response_format"]["type"], "json_object");
1503 }
1504
1505 #[test]
1506 fn test_build_chat_request_applies_directive_and_system() {
1507 let req = make_client().build_chat_request(
1508 &[Message::user("hi")],
1509 Some("sys"),
1510 &[ToolDefinition {
1511 name: "emit_x".to_string(),
1512 description: "emit".to_string(),
1513 parameters: serde_json::json!({ "type": "object" }),
1514 }],
1515 Some(&structured::StructuredDirective {
1516 force_tool: Some("emit_x".to_string()),
1517 response_format: None,
1518 }),
1519 );
1520 assert_eq!(req["messages"][0]["role"], "system");
1521 assert_eq!(req["tool_choice"]["function"]["name"], "emit_x");
1522 assert_eq!(req["tools"][0]["function"]["name"], "emit_x");
1523 }
1524
1525 #[test]
1526 fn test_build_chat_request_without_directive_is_plain() {
1527 let req = make_client().build_chat_request(&[Message::user("hi")], None, &[], None);
1528 assert!(req.get("tool_choice").is_none());
1529 assert!(req.get("response_format").is_none());
1530 assert!(req.get("logprobs").is_none());
1531 assert!(req.get("top_logprobs").is_none());
1532 }
1533
1534 #[test]
1535 fn test_build_chat_request_includes_logprob_options_when_enabled() {
1536 let req = make_client().with_top_logprobs(1).build_chat_request(
1537 &[Message::user("hi")],
1538 None,
1539 &[],
1540 None,
1541 );
1542 assert_eq!(req["logprobs"], true);
1543 assert_eq!(req["top_logprobs"], 1);
1544 }
1545
1546 #[test]
1547 fn test_parse_openai_token_logprobs() {
1548 let parsed = openai_logprobs_to_token_logprobs(&OpenAiChoiceLogprobs {
1549 content: Some(vec![OpenAiTokenLogprob {
1550 token: "hello".to_string(),
1551 logprob: -0.25,
1552 bytes: Some(vec![104, 101, 108, 108, 111]),
1553 top_logprobs: vec![OpenAiTopLogprob {
1554 token: "hi".to_string(),
1555 logprob: -1.5,
1556 bytes: Some(vec![104, 105]),
1557 }],
1558 }]),
1559 });
1560 assert_eq!(parsed.len(), 1);
1561 assert_eq!(parsed[0].token, "hello");
1562 assert_eq!(parsed[0].logprob, -0.25);
1563 assert_eq!(
1564 parsed[0].bytes.as_deref(),
1565 Some(&[104, 101, 108, 108, 111][..])
1566 );
1567 assert_eq!(parsed[0].top_logprobs[0].token, "hi");
1568 assert_eq!(parsed[0].top_logprobs[0].logprob, -1.5);
1569 }
1570
1571 #[test]
1572 fn test_native_structured_support_is_json_schema() {
1573 assert_eq!(
1574 make_client().native_structured_support(),
1575 structured::NativeStructuredSupport::JsonSchema
1576 );
1577 }
1578}