Skip to main content

lc_providers/openai/
chat.rs

1// src/language_models/openai/chat.rs
2//! OpenAI chat model implementation.
3
4use async_trait::async_trait;
5use futures_util::Stream;
6use serde::Deserialize;
7use serde_json::json;
8use std::pin::Pin;
9
10use super::OpenAIConfig;
11use lc_callbacks::{RunTree, RunType};
12use lc_core::language_models::{BaseChatModel, BaseLanguageModel, LLMResult, TokenUsage};
13use lc_core::runnables::Runnable;
14use lc_core::tools::{StructuredOutput, ToolDefinition};
15use lc_core::RunnableConfig;
16use lc_schema::Message;
17use schemars::JsonSchema;
18use serde::de::DeserializeOwned;
19use std::marker::PhantomData;
20
21/// OpenAI chat client for GPT models.
22#[derive(Clone)]
23pub struct OpenAIChat {
24    pub(crate) config: OpenAIConfig,
25    pub(crate) client: reqwest::Client,
26}
27
28impl OpenAIChat {
29    /// Creates a new OpenAIChat with the given configuration.
30    pub fn new(config: OpenAIConfig) -> Self {
31        Self {
32            config,
33            client: reqwest::Client::new(),
34        }
35    }
36
37    /// Creates an OpenAIChat from environment variables, returning a Result.
38    pub fn from_env_result() -> Result<Self, OpenAIError> {
39        let config = OpenAIConfig::from_env_result()?;
40        Ok(Self::new(config))
41    }
42
43    /// Converts a Message to OpenAI API format.
44    fn message_to_openai_format(message: &Message) -> serde_json::Value {
45        match &message.message_type {
46            lc_schema::MessageType::System => json!({
47                "role": "system",
48                "content": message.content,
49            }),
50            lc_schema::MessageType::Human => {
51                if message.has_images() {
52                    let mut content = vec![json!({"type": "text", "text": &message.content})];
53                    for img in &message.images {
54                        content.push(json!({"type": "image_url", "image_url": {"url": &img.url}}));
55                    }
56                    json!({"role": "user", "content": content})
57                } else {
58                    json!({"role": "user", "content": &message.content})
59                }
60            }
61            lc_schema::MessageType::AI => {
62                let mut msg = json!({
63                    "role": "assistant",
64                    "content": message.content,
65                });
66                if let Some(tool_calls) = &message.tool_calls {
67                    msg["tool_calls"] =
68                        serde_json::to_value(tool_calls).unwrap_or(serde_json::Value::Null);
69                }
70                msg
71            }
72            lc_schema::MessageType::Tool { tool_call_id } => json!({
73                "role": "tool",
74                "tool_call_id": tool_call_id,
75                "content": message.content,
76            }),
77        }
78    }
79
80    /// Builds the API request body.
81    fn build_request_body(&self, messages: Vec<Message>, stream: bool) -> serde_json::Value {
82        let openai_messages: Vec<serde_json::Value> = messages
83            .iter()
84            .map(Self::message_to_openai_format)
85            .collect();
86
87        let mut body = json!({
88            "model": self.config.model,
89            "messages": openai_messages,
90            "stream": stream,
91        });
92
93        if let Some(temp) = self.config.temperature {
94            body["temperature"] = json!(temp);
95        }
96
97        if let Some(max) = self.config.max_tokens {
98            body["max_tokens"] = json!(max);
99        }
100
101        if let Some(top_p) = self.config.top_p {
102            body["top_p"] = json!(top_p);
103        }
104
105        if let Some(tools) = &self.config.tools {
106            body["tools"] = serde_json::to_value(tools).unwrap_or(serde_json::Value::Null);
107        }
108
109        if let Some(tool_choice) = &self.config.tool_choice {
110            body["tool_choice"] = json!(tool_choice);
111        }
112
113        body
114    }
115
116    /// Binds tool definitions for function calling.
117    pub fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self {
118        let config = OpenAIConfig {
119            tools: Some(tools),
120            ..self.config.clone()
121        };
122        Self {
123            config,
124            client: self.client.clone(),
125        }
126    }
127
128    /// Sets the tool choice strategy.
129    pub fn with_tool_choice(mut self, choice: impl Into<String>) -> Self {
130        self.config.tool_choice = Some(choice.into());
131        self
132    }
133
134    /// Enables structured JSON output with schema validation.
135    pub fn with_structured_output<T: DeserializeOwned + JsonSchema>(
136        &self,
137    ) -> StructuredOutputMethod<T> {
138        use schemars::schema_for;
139        let schema = serde_json::to_value(schema_for!(T)).unwrap_or_else(|_| {
140            // H64: Schema generation should not silently produce null
141            serde_json::json!({"type": "object", "properties": {}})
142        });
143
144        let tool = ToolDefinition::new("structured_output", "Return structured JSON output")
145            .with_parameters(schema)
146            .with_strict(true);
147
148        let config = OpenAIConfig {
149            tools: Some(vec![tool]),
150            tool_choice: Some("auto".to_string()),
151            ..self.config.clone()
152        };
153
154        StructuredOutputMethod {
155            config,
156            client: self.client.clone(),
157            _phantom: PhantomData,
158        }
159    }
160}
161
162/// Method for structured output calls
163pub struct StructuredOutputMethod<T: DeserializeOwned + JsonSchema> {
164    config: OpenAIConfig,
165    client: reqwest::Client,
166    _phantom: PhantomData<T>,
167}
168
169impl<T: DeserializeOwned + JsonSchema> StructuredOutputMethod<T> {
170    pub async fn invoke(&self, messages: Vec<Message>) -> Result<T, OpenAIError> {
171        let chat = OpenAIChat {
172            config: self.config.clone(),
173            client: self.client.clone(),
174        };
175
176        let result = chat.chat_internal(messages).await?;
177        let structured = StructuredOutput::<T>::new(result);
178        structured
179            .parse()
180            .map_err(|e| OpenAIError::Parse(e.to_string()))
181    }
182}
183
184#[async_trait]
185impl Runnable<Vec<Message>, LLMResult> for OpenAIChat {
186    type Error = OpenAIError;
187
188    async fn invoke(
189        &self,
190        input: Vec<Message>,
191        _config: Option<RunnableConfig>,
192    ) -> Result<LLMResult, Self::Error> {
193        self.chat(input, _config).await
194    }
195
196    async fn stream(
197        &self,
198        input: Vec<Message>,
199        config: Option<RunnableConfig>,
200    ) -> Result<Pin<Box<dyn Stream<Item = Result<LLMResult, Self::Error>> + Send>>, Self::Error>
201    {
202        use futures_util::StreamExt;
203
204        let model = self.config.model.clone();
205        let (temp, max) = crate::sampling::sampling_overrides(&config);
206        let mut effective = self.clone();
207        if let Some(t) = temp {
208            effective.config.temperature = Some(t);
209        }
210        if let Some(m) = max {
211            effective.config.max_tokens = Some(m);
212        }
213        let token_stream = effective.stream_chat_internal(input).await?;
214
215        // H4: True streaming — emit one LLMResult per token instead of
216        // collecting all tokens first and emitting a single result.
217        let stream = token_stream.map(move |token_result| match token_result {
218            Ok(token) => Ok(LLMResult {
219                content: token,
220                model: model.clone(),
221                token_usage: None,
222                tool_calls: None,
223                thinking_content: None,
224            }),
225            Err(e) => Err(e),
226        });
227
228        Ok(Box::pin(stream))
229    }
230}
231
232#[async_trait]
233impl BaseLanguageModel<Vec<Message>, LLMResult> for OpenAIChat {
234    fn model_name(&self) -> &str {
235        &self.config.model
236    }
237
238    fn get_num_tokens(&self, text: &str) -> usize {
239        lc_core::token_counter::count_tokens(text).unwrap_or_else(|e| {
240            // 编码器加载失败时按字节数高估(宁可略高,不静默按 0 算导致路由/截断误判)
241            log::warn!("token 计数失败,回退为按字节数估算: {e}");
242            text.len()
243        })
244    }
245
246    fn temperature(&self) -> Option<f32> {
247        self.config.temperature
248    }
249
250    fn max_tokens(&self) -> Option<usize> {
251        self.config.max_tokens
252    }
253
254    fn with_temperature(mut self, temp: f32) -> Self {
255        self.config.temperature = Some(temp);
256        self
257    }
258
259    fn with_max_tokens(mut self, max: usize) -> Self {
260        self.config.max_tokens = Some(max);
261        self
262    }
263}
264
265#[async_trait]
266impl BaseChatModel for OpenAIChat {
267    async fn chat(
268        &self,
269        messages: Vec<Message>,
270        config: Option<RunnableConfig>,
271    ) -> Result<LLMResult, Self::Error> {
272        let run_name = config
273            .as_ref()
274            .and_then(|c| c.run_name.clone())
275            .unwrap_or_else(|| format!("{}:chat", self.config.model));
276
277        let mut run = RunTree::new(
278            run_name,
279            RunType::Llm,
280            json!({
281                "messages": messages.iter().map(|m| m.content.clone()).collect::<Vec<_>>(),
282                "model": self.config.model,
283            }),
284        );
285
286        if let Some(ref cfg) = config {
287            for tag in &cfg.tags {
288                run = run.with_tag(tag.clone());
289            }
290            for (key, value) in &cfg.metadata {
291                run = run.with_metadata(key.clone(), value.clone());
292            }
293        }
294
295        if let Some(ref cfg) = config {
296            if let Some(ref callbacks) = cfg.callbacks {
297                for handler in callbacks.handlers() {
298                    handler.on_llm_start(&run, &messages).await;
299                }
300            }
301        }
302
303        let (temp, max) = crate::sampling::sampling_overrides(&config);
304        let mut effective = self.clone();
305        if let Some(t) = temp {
306            effective.config.temperature = Some(t);
307        }
308        if let Some(m) = max {
309            effective.config.max_tokens = Some(m);
310        }
311
312        // Q4: honor `config.streaming` — aggregate the streaming token stream
313        // into a single LLMResult instead of ignoring the field.
314        let result = if effective.config.streaming {
315            let stream = effective.stream_chat_internal(messages.clone()).await?;
316            let content = Self::aggregate_stream(stream).await?;
317            Ok(LLMResult {
318                content,
319                model: effective.config.model.clone(),
320                token_usage: None,
321                tool_calls: None,
322                thinking_content: None,
323            })
324        } else {
325            effective.chat_internal(messages.clone()).await
326        };
327
328        match result {
329            Ok(response) => {
330                run.end(json!({
331                    "content": &response.content,
332                    "model": &response.model,
333                    "token_usage": &response.token_usage,
334                }));
335
336                if let Some(ref cfg) = config {
337                    if let Some(ref callbacks) = cfg.callbacks {
338                        for handler in callbacks.handlers() {
339                            handler.on_llm_end(&run, &response.content).await;
340                        }
341                    }
342                }
343
344                Ok(response)
345            }
346            Err(e) => {
347                run.end_with_error(e.to_string());
348
349                if let Some(ref cfg) = config {
350                    if let Some(ref callbacks) = cfg.callbacks {
351                        for handler in callbacks.handlers() {
352                            handler.on_llm_error(&run, &e.to_string()).await;
353                        }
354                    }
355                }
356
357                Err(e)
358            }
359        }
360    }
361
362    async fn stream_chat(
363        &self,
364        messages: Vec<Message>,
365        config: Option<RunnableConfig>,
366    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error> {
367        use futures_util::StreamExt;
368
369        let run_name = config
370            .as_ref()
371            .and_then(|c| c.run_name.clone())
372            .unwrap_or_else(|| format!("{}:stream", self.config.model));
373
374        let run = RunTree::new(
375            run_name,
376            RunType::Llm,
377            json!({
378                "messages": messages.len(),
379                "model": self.config.model,
380            }),
381        );
382
383        if let Some(ref cfg) = config {
384            if let Some(ref callbacks) = cfg.callbacks {
385                for handler in callbacks.handlers() {
386                    handler.on_llm_start(&run, &messages).await;
387                }
388            }
389        }
390
391        let (temp, max) = crate::sampling::sampling_overrides(&config);
392        let mut effective = self.clone();
393        if let Some(t) = temp {
394            effective.config.temperature = Some(t);
395        }
396        if let Some(m) = max {
397            effective.config.max_tokens = Some(m);
398        }
399        let stream = effective.stream_chat_internal(messages).await?;
400
401        let callbacks = config.and_then(|c| c.callbacks);
402        let stream = stream.then(move |token_result| {
403            let cbs = callbacks.clone();
404            let run = run.clone();
405            async move {
406                if let Some(ref cbs) = cbs {
407                    if let Ok(ref token) = token_result {
408                        for handler in cbs.handlers() {
409                            handler.on_llm_new_token(&run, token).await;
410                        }
411                    }
412                }
413                token_result
414            }
415        });
416
417        Ok(Box::pin(stream))
418    }
419
420    fn bind_tools(
421        &self,
422        tools: Vec<ToolDefinition>,
423    ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
424        // Expose the inherent tool-binding capability at the trait level so it
425        // survives being wrapped by `ChatModelWrapper` / `LLMClient` (Q1).
426        Some(Box::new(self.bind_tools(tools)))
427    }
428}
429
430impl OpenAIChat {
431    async fn chat_internal(&self, messages: Vec<Message>) -> Result<LLMResult, OpenAIError> {
432        let url = format!("{}/chat/completions", self.config.base_url);
433        let body = self.build_request_body(messages, false);
434
435        let response = self
436            .client
437            .post(&url)
438            .header("Authorization", format!("Bearer {}", self.config.api_key))
439            .header("Content-Type", "application/json")
440            .json(&body)
441            .send()
442            .await
443            .map_err(|e| OpenAIError::Http(e.to_string()))?;
444
445        let status = response.status();
446        if !status.is_success() {
447            let error_text = response.text().await.unwrap_or_default();
448            return Err(OpenAIError::Api(format!("HTTP {}: {}", status, error_text)));
449        }
450
451        let chat_response: OpenAIChatResponse = response
452            .json()
453            .await
454            .map_err(|e| OpenAIError::Parse(e.to_string()))?;
455
456        let choice = chat_response
457            .choices
458            .first()
459            .ok_or_else(|| OpenAIError::Api("No choices in response".to_string()))?;
460        let message = &choice.message;
461
462        Ok(Self::llm_result_from_message(
463            message,
464            chat_response.model,
465            chat_response.usage,
466        ))
467    }
468
469    /// Builds the `LLMResult` from a parsed response message (Q3).
470    ///
471    /// Thinking models (glm-5.2, DeepSeek-R1) may return an empty `content` with
472    /// the actual reasoning in `reasoning_content`. `content` stays empty in that
473    /// case — it is never filled from `reasoning_content` — and the reasoning only
474    /// goes into `thinking_content`.
475    fn llm_result_from_message(
476        message: &OpenAIMessage,
477        model: String,
478        usage: Option<OpenAIUsage>,
479    ) -> LLMResult {
480        let content = message
481            .content
482            .clone()
483            .filter(|c| !c.is_empty())
484            .unwrap_or_default();
485
486        let thinking_content = message.reasoning_content.clone().filter(|c| !c.is_empty());
487
488        LLMResult {
489            content,
490            model,
491            token_usage: usage.map(|u| TokenUsage {
492                prompt_tokens: u.prompt_tokens,
493                completion_tokens: u.completion_tokens,
494                total_tokens: u.total_tokens,
495            }),
496            tool_calls: message.tool_calls.clone(),
497            thinking_content,
498        }
499    }
500
501    async fn stream_chat_internal(
502        &self,
503        messages: Vec<Message>,
504    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, OpenAIError>> + Send>>, OpenAIError> {
505        use super::sse::SSEParser;
506        use std::sync::{Arc, Mutex};
507
508        let url = format!("{}/chat/completions", self.config.base_url);
509        let body = self.build_request_body(messages, true);
510
511        let response = self
512            .client
513            .post(&url)
514            .header("Authorization", format!("Bearer {}", self.config.api_key))
515            .header("Content-Type", "application/json")
516            .json(&body)
517            .send()
518            .await
519            .map_err(|e| OpenAIError::Http(e.to_string()))?;
520
521        let status = response.status();
522        if !status.is_success() {
523            let error_text = response.text().await.unwrap_or_default();
524            return Err(OpenAIError::Api(format!("HTTP {}: {}", status, error_text)));
525        }
526
527        let byte_stream = response.bytes_stream();
528
529        let parser = Arc::new(Mutex::new(SSEParser::new()));
530
531        let parser_clone = parser.clone();
532        // M18: Use bounded channel to prevent OOM with slow consumers
533        let (tx, rx) = tokio::sync::mpsc::channel::<Result<String, OpenAIError>>(64);
534
535        tokio::spawn(async move {
536            use futures_util::StreamExt;
537            let mut byte_stream = byte_stream;
538            while let Some(chunk_result) = byte_stream.next().await {
539                // H2 fix: propagate network errors to the consumer
540                // Must be done OUTSIDE the mutex scope to avoid Send issue
541                let chunk_bytes = match chunk_result {
542                    Ok(bytes) => bytes,
543                    Err(e) => {
544                        let _ = tx.send(Err(OpenAIError::Http(e.to_string()))).await;
545                        return;
546                    }
547                };
548
549                let events = {
550                    let mut parser_guard = parser_clone.lock().unwrap_or_else(|e| e.into_inner());
551                    let chunk_str = String::from_utf8_lossy(&chunk_bytes);
552                    parser_guard.parse(&chunk_str)
553                };
554                // parser_guard is dropped here, before any await
555
556                for event in events {
557                    if event.is_done() {
558                        break;
559                    }
560                    // 解析失败的 SSE chunk 不再静默丢弃:记 error 日志,
561                    // 避免流式回复因单条坏数据被截断却毫无提示
562                    match event.parse_openai_chunk() {
563                        Ok(Some(chunk)) => {
564                            if let Some(choice) = chunk.choices.first() {
565                                if let Some(content) = &choice.delta.content {
566                                    if tx.send(Ok(content.clone())).await.is_err() {
567                                        return;
568                                    }
569                                }
570                            }
571                        }
572                        Ok(None) => {}
573                        Err(e) => {
574                            log::error!("解析流式 SSE chunk 失败(已跳过该 token): {}", e);
575                        }
576                    }
577                }
578            }
579        });
580        let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
581
582        Ok(Box::pin(stream))
583    }
584
585    /// Aggregates a token stream into a single string (Q4).
586    ///
587    /// This is the piece that makes `config.streaming` observable: the
588    /// non-streaming `chat()` path consumes the token stream through here.
589    async fn aggregate_stream(
590        mut stream: Pin<Box<dyn Stream<Item = Result<String, OpenAIError>> + Send>>,
591    ) -> Result<String, OpenAIError> {
592        use futures_util::StreamExt;
593        let mut content = String::new();
594        while let Some(item) = stream.next().await {
595            content.push_str(&item?);
596        }
597        Ok(content)
598    }
599}
600
601/// OpenAI 错误类型
602#[derive(Debug)]
603pub enum OpenAIError {
604    Http(String),
605    Api(String),
606    Parse(String),
607}
608
609impl std::fmt::Display for OpenAIError {
610    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611        match self {
612            OpenAIError::Http(msg) => write!(f, "HTTP 错误: {}", msg),
613            OpenAIError::Api(msg) => write!(f, "API 错误: {}", msg),
614            OpenAIError::Parse(msg) => write!(f, "解析错误: {}", msg),
615        }
616    }
617}
618
619impl std::error::Error for OpenAIError {}
620
621impl From<String> for OpenAIError {
622    fn from(s: String) -> Self {
623        OpenAIError::Api(s)
624    }
625}
626
627#[cfg(test)]
628mod tests_env {
629    use super::*;
630
631    use std::env;
632
633    fn save_and_set(key: &str, value: &str) -> Option<String> {
634        let old = env::var(key).ok();
635        env::set_var(key, value);
636        old
637    }
638
639    fn restore(key: &str, old: Option<String>) {
640        match old {
641            Some(v) => env::set_var(key, v),
642            None => env::remove_var(key),
643        }
644    }
645
646    #[test]
647    fn test_from_env_result_ok_when_key_set() {
648        let _lock = crate::ENV_TEST_LOCK
649            .lock()
650            .unwrap_or_else(|e| e.into_inner());
651        let old = save_and_set("OPENAI_API_KEY", "test-key-123");
652        assert!(OpenAIChat::from_env_result().is_ok());
653        restore("OPENAI_API_KEY", old);
654    }
655
656    #[test]
657    fn test_from_env_result_err_when_key_missing() {
658        let _lock = crate::ENV_TEST_LOCK
659            .lock()
660            .unwrap_or_else(|e| e.into_inner());
661        let old = env::var("OPENAI_API_KEY").ok();
662        env::remove_var("OPENAI_API_KEY");
663        assert!(OpenAIChat::from_env_result().is_err());
664        restore("OPENAI_API_KEY", old);
665    }
666}
667
668#[cfg(test)]
669mod tests_q3_q4 {
670    use super::*;
671
672    fn message(content: Option<&str>, reasoning: Option<&str>) -> OpenAIMessage {
673        OpenAIMessage {
674            role: "assistant".to_string(),
675            content: content.map(|s| s.to_string()),
676            reasoning_content: reasoning.map(|s| s.to_string()),
677            tool_calls: None,
678        }
679    }
680
681    #[test]
682    fn test_llm_result_keeps_content_when_non_empty() {
683        let msg = message(Some("Hello"), Some("hidden chain-of-thought"));
684        let result = OpenAIChat::llm_result_from_message(
685            &msg,
686            "gpt-test".to_string(),
687            Some(OpenAIUsage {
688                prompt_tokens: 10,
689                completion_tokens: 20,
690                total_tokens: 30,
691            }),
692        );
693
694        assert_eq!(result.content, "Hello");
695        assert_eq!(
696            result.thinking_content.as_deref(),
697            Some("hidden chain-of-thought")
698        );
699        assert_eq!(result.model, "gpt-test");
700        let usage = result.token_usage.unwrap();
701        assert_eq!(usage.prompt_tokens, 10);
702        assert_eq!(usage.completion_tokens, 20);
703        assert_eq!(usage.total_tokens, 30);
704    }
705
706    #[test]
707    fn test_llm_result_reasoning_does_not_leak_into_content() {
708        // Q3: reasoning-only responses keep `content` empty — no fallback.
709        let msg = message(Some(""), Some("reasoning only"));
710        let result = OpenAIChat::llm_result_from_message(&msg, "gpt-test".to_string(), None);
711
712        assert_eq!(result.content, "");
713        assert_eq!(result.thinking_content.as_deref(), Some("reasoning only"));
714    }
715
716    #[test]
717    fn test_llm_result_empty_content_no_thinking() {
718        let msg = message(None, Some(""));
719        let result = OpenAIChat::llm_result_from_message(&msg, "gpt-test".to_string(), None);
720
721        assert_eq!(result.content, "");
722        assert!(result.thinking_content.is_none());
723    }
724
725    #[tokio::test]
726    async fn test_aggregate_stream_concatenates_tokens_in_order() {
727        // Q4: the aggregation helper produces the full content in order.
728        let stream: Pin<Box<dyn Stream<Item = Result<String, OpenAIError>> + Send>> =
729            Box::pin(futures_util::stream::iter(vec![
730                Ok("Hello".to_string()),
731                Ok(", ".to_string()),
732                Ok("world".to_string()),
733            ]));
734
735        let content = OpenAIChat::aggregate_stream(stream).await.unwrap();
736        assert_eq!(content, "Hello, world");
737    }
738
739    #[tokio::test]
740    async fn test_aggregate_stream_stops_on_error() {
741        let stream: Pin<Box<dyn Stream<Item = Result<String, OpenAIError>> + Send>> =
742            Box::pin(futures_util::stream::iter(vec![
743                Ok("Hello".to_string()),
744                Err(OpenAIError::Api("boom".to_string())),
745                Ok("never".to_string()),
746            ]));
747
748        let err = OpenAIChat::aggregate_stream(stream).await.unwrap_err();
749        assert!(matches!(err, OpenAIError::Api(_)));
750    }
751}
752
753/// OpenAI 响应结构
754#[derive(Debug, Deserialize)]
755#[allow(dead_code)]
756struct OpenAIChatResponse {
757    id: String,
758    object: String,
759    created: i64,
760    model: String,
761    choices: Vec<OpenAIChoice>,
762    usage: Option<OpenAIUsage>,
763}
764
765#[derive(Debug, Deserialize)]
766#[allow(dead_code)]
767struct OpenAIChoice {
768    index: i32,
769    message: OpenAIMessage,
770    finish_reason: Option<String>,
771}
772
773#[derive(Debug, Deserialize)]
774#[allow(dead_code)]
775struct OpenAIMessage {
776    role: String,
777    content: Option<String>,
778    /// 推理模型的思维链内容(如 glm-5.2, DeepSeek-R1 等)
779    reasoning_content: Option<String>,
780    tool_calls: Option<Vec<lc_core::tools::ToolCall>>,
781}
782
783#[derive(Debug, Deserialize)]
784#[allow(dead_code)]
785struct OpenAIUsage {
786    prompt_tokens: usize,
787    completion_tokens: usize,
788    total_tokens: usize,
789}