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