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 token_stream = self.stream_chat_internal(input).await?;
216
217        // H4: True streaming — emit one LLMResult per token instead of
218        // collecting all tokens first and emitting a single result.
219        let stream = token_stream.map(move |token_result| match token_result {
220            Ok(token) => Ok(LLMResult {
221                content: token,
222                model: model.clone(),
223                token_usage: None,
224                tool_calls: None,
225                thinking_content: None,
226            }),
227            Err(e) => Err(e),
228        });
229
230        Ok(Box::pin(stream))
231    }
232}
233
234#[async_trait]
235impl BaseLanguageModel<Vec<Message>, LLMResult> for OpenAIChat {
236    fn model_name(&self) -> &str {
237        &self.config.model
238    }
239
240    fn get_num_tokens(&self, text: &str) -> usize {
241        lc_core::token_counter::count_tokens(text)
242    }
243
244    fn temperature(&self) -> Option<f32> {
245        self.config.temperature
246    }
247
248    fn max_tokens(&self) -> Option<usize> {
249        self.config.max_tokens
250    }
251
252    fn with_temperature(mut self, temp: f32) -> Self {
253        self.config.temperature = Some(temp);
254        self
255    }
256
257    fn with_max_tokens(mut self, max: usize) -> Self {
258        self.config.max_tokens = Some(max);
259        self
260    }
261}
262
263#[async_trait]
264impl BaseChatModel for OpenAIChat {
265    async fn chat(
266        &self,
267        messages: Vec<Message>,
268        config: Option<RunnableConfig>,
269    ) -> Result<LLMResult, Self::Error> {
270        let run_name = config
271            .as_ref()
272            .and_then(|c| c.run_name.clone())
273            .unwrap_or_else(|| format!("{}:chat", self.config.model));
274
275        let mut run = RunTree::new(
276            run_name,
277            RunType::Llm,
278            json!({
279                "messages": messages.iter().map(|m| m.content.clone()).collect::<Vec<_>>(),
280                "model": self.config.model,
281            }),
282        );
283
284        if let Some(ref cfg) = config {
285            for tag in &cfg.tags {
286                run = run.with_tag(tag.clone());
287            }
288            for (key, value) in &cfg.metadata {
289                run = run.with_metadata(key.clone(), value.clone());
290            }
291        }
292
293        if let Some(ref cfg) = config {
294            if let Some(ref callbacks) = cfg.callbacks {
295                for handler in callbacks.handlers() {
296                    handler.on_llm_start(&run, &messages).await;
297                }
298            }
299        }
300
301        let result = self.chat_internal(messages.clone()).await;
302
303        match result {
304            Ok(response) => {
305                run.end(json!({
306                    "content": &response.content,
307                    "model": &response.model,
308                    "token_usage": &response.token_usage,
309                }));
310
311                if let Some(ref cfg) = config {
312                    if let Some(ref callbacks) = cfg.callbacks {
313                        for handler in callbacks.handlers() {
314                            handler.on_llm_end(&run, &response.content).await;
315                        }
316                    }
317                }
318
319                Ok(response)
320            }
321            Err(e) => {
322                run.end_with_error(e.to_string());
323
324                if let Some(ref cfg) = config {
325                    if let Some(ref callbacks) = cfg.callbacks {
326                        for handler in callbacks.handlers() {
327                            handler.on_llm_error(&run, &e.to_string()).await;
328                        }
329                    }
330                }
331
332                Err(e)
333            }
334        }
335    }
336
337    async fn stream_chat(
338        &self,
339        messages: Vec<Message>,
340        config: Option<RunnableConfig>,
341    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error> {
342        use futures_util::StreamExt;
343
344        let run_name = config
345            .as_ref()
346            .and_then(|c| c.run_name.clone())
347            .unwrap_or_else(|| format!("{}:stream", self.config.model));
348
349        let run = RunTree::new(
350            run_name,
351            RunType::Llm,
352            json!({
353                "messages": messages.len(),
354                "model": self.config.model,
355            }),
356        );
357
358        if let Some(ref cfg) = config {
359            if let Some(ref callbacks) = cfg.callbacks {
360                for handler in callbacks.handlers() {
361                    handler.on_llm_start(&run, &messages).await;
362                }
363            }
364        }
365
366        let stream = self.stream_chat_internal(messages).await?;
367
368        let callbacks = config.and_then(|c| c.callbacks);
369        let stream = stream.then(move |token_result| {
370            let cbs = callbacks.clone();
371            let run = run.clone();
372            async move {
373                if let Some(ref cbs) = cbs {
374                    if let Ok(ref token) = token_result {
375                        for handler in cbs.handlers() {
376                            handler.on_llm_new_token(&run, token).await;
377                        }
378                    }
379                }
380                token_result
381            }
382        });
383
384        Ok(Box::pin(stream))
385    }
386}
387
388impl OpenAIChat {
389    async fn chat_internal(&self, messages: Vec<Message>) -> Result<LLMResult, OpenAIError> {
390        let url = format!("{}/chat/completions", self.config.base_url);
391        let body = self.build_request_body(messages, false);
392
393        let response = self
394            .client
395            .post(&url)
396            .header("Authorization", format!("Bearer {}", self.config.api_key))
397            .header("Content-Type", "application/json")
398            .json(&body)
399            .send()
400            .await
401            .map_err(|e| OpenAIError::Http(e.to_string()))?;
402
403        let status = response.status();
404        if !status.is_success() {
405            let error_text = response.text().await.unwrap_or_default();
406            return Err(OpenAIError::Api(format!("HTTP {}: {}", status, error_text)));
407        }
408
409        let chat_response: OpenAIChatResponse = response
410            .json()
411            .await
412            .map_err(|e| OpenAIError::Parse(e.to_string()))?;
413
414        let choice = chat_response
415            .choices
416            .first()
417            .ok_or_else(|| OpenAIError::Api("No choices in response".to_string()))?;
418        let message = &choice.message;
419
420        // 推理模型(如 glm-5.2, DeepSeek-R1)的 content 可能为空,
421        // 实际回答在 reasoning_content 中;优先用 content,fallback 到 reasoning_content
422        // reasoning_content 应存入 thinking_content 而非 content (H63)
423        let content = message
424            .content
425            .clone()
426            .filter(|c| !c.is_empty())
427            .unwrap_or_default();
428
429        let thinking_content = message.reasoning_content.clone().filter(|c| !c.is_empty());
430
431        // If content is empty but reasoning_content exists, use reasoning as content (backward compat)
432        let content = if content.is_empty() {
433            message.reasoning_content.clone().unwrap_or_default()
434        } else {
435            content
436        };
437
438        Ok(LLMResult {
439            content,
440            model: chat_response.model,
441            token_usage: chat_response.usage.map(|u| TokenUsage {
442                prompt_tokens: u.prompt_tokens,
443                completion_tokens: u.completion_tokens,
444                total_tokens: u.total_tokens,
445            }),
446            tool_calls: message.tool_calls.clone(),
447            thinking_content,
448        })
449    }
450
451    async fn stream_chat_internal(
452        &self,
453        messages: Vec<Message>,
454    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, OpenAIError>> + Send>>, OpenAIError> {
455        use super::sse::SSEParser;
456        use std::sync::{Arc, Mutex};
457
458        let url = format!("{}/chat/completions", self.config.base_url);
459        let body = self.build_request_body(messages, true);
460
461        let response = self
462            .client
463            .post(&url)
464            .header("Authorization", format!("Bearer {}", self.config.api_key))
465            .header("Content-Type", "application/json")
466            .json(&body)
467            .send()
468            .await
469            .map_err(|e| OpenAIError::Http(e.to_string()))?;
470
471        let status = response.status();
472        if !status.is_success() {
473            let error_text = response.text().await.unwrap_or_default();
474            return Err(OpenAIError::Api(format!("HTTP {}: {}", status, error_text)));
475        }
476
477        let byte_stream = response.bytes_stream();
478
479        let parser = Arc::new(Mutex::new(SSEParser::new()));
480
481        let parser_clone = parser.clone();
482        // M18: Use bounded channel to prevent OOM with slow consumers
483        let (tx, rx) = tokio::sync::mpsc::channel::<Result<String, OpenAIError>>(64);
484
485        tokio::spawn(async move {
486            use futures_util::StreamExt;
487            let mut byte_stream = byte_stream;
488            while let Some(chunk_result) = byte_stream.next().await {
489                // H2 fix: propagate network errors to the consumer
490                // Must be done OUTSIDE the mutex scope to avoid Send issue
491                let chunk_bytes = match chunk_result {
492                    Ok(bytes) => bytes,
493                    Err(e) => {
494                        let _ = tx.send(Err(OpenAIError::Http(e.to_string()))).await;
495                        return;
496                    }
497                };
498
499                let events = {
500                    let mut parser_guard = parser_clone.lock().unwrap_or_else(|e| e.into_inner());
501                    let chunk_str = String::from_utf8_lossy(&chunk_bytes);
502                    parser_guard.parse(&chunk_str)
503                };
504                // parser_guard is dropped here, before any await
505
506                for event in events {
507                    if event.is_done() {
508                        break;
509                    }
510                    if let Ok(Some(chunk)) = event.parse_openai_chunk() {
511                        if let Some(choice) = chunk.choices.first() {
512                            if let Some(content) = &choice.delta.content {
513                                if tx.send(Ok(content.clone())).await.is_err() {
514                                    return;
515                                }
516                            }
517                        }
518                    }
519                }
520            }
521        });
522        let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
523
524        Ok(Box::pin(stream))
525    }
526}
527
528/// OpenAI 错误类型
529#[derive(Debug)]
530pub enum OpenAIError {
531    Http(String),
532    Api(String),
533    Parse(String),
534}
535
536impl std::fmt::Display for OpenAIError {
537    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538        match self {
539            OpenAIError::Http(msg) => write!(f, "HTTP 错误: {}", msg),
540            OpenAIError::Api(msg) => write!(f, "API 错误: {}", msg),
541            OpenAIError::Parse(msg) => write!(f, "解析错误: {}", msg),
542        }
543    }
544}
545
546impl std::error::Error for OpenAIError {}
547
548impl From<String> for OpenAIError {
549    fn from(s: String) -> Self {
550        OpenAIError::Api(s)
551    }
552}
553
554#[cfg(test)]
555mod tests_env {
556    use super::*;
557
558    use std::env;
559
560    fn save_and_set(key: &str, value: &str) -> Option<String> {
561        let old = env::var(key).ok();
562        env::set_var(key, value);
563        old
564    }
565
566    fn restore(key: &str, old: Option<String>) {
567        match old {
568            Some(v) => env::set_var(key, v),
569            None => env::remove_var(key),
570        }
571    }
572
573    #[test]
574    fn test_from_env_result_ok_when_key_set() {
575        let _lock = crate::ENV_TEST_LOCK
576            .lock()
577            .unwrap_or_else(|e| e.into_inner());
578        let old = save_and_set("OPENAI_API_KEY", "test-key-123");
579        assert!(OpenAIChat::from_env_result().is_ok());
580        restore("OPENAI_API_KEY", old);
581    }
582
583    #[test]
584    fn test_from_env_result_err_when_key_missing() {
585        let _lock = crate::ENV_TEST_LOCK
586            .lock()
587            .unwrap_or_else(|e| e.into_inner());
588        let old = env::var("OPENAI_API_KEY").ok();
589        env::remove_var("OPENAI_API_KEY");
590        assert!(OpenAIChat::from_env_result().is_err());
591        restore("OPENAI_API_KEY", old);
592    }
593}
594
595/// OpenAI 响应结构
596#[derive(Debug, Deserialize)]
597#[allow(dead_code)]
598struct OpenAIChatResponse {
599    id: String,
600    object: String,
601    created: i64,
602    model: String,
603    choices: Vec<OpenAIChoice>,
604    usage: Option<OpenAIUsage>,
605}
606
607#[derive(Debug, Deserialize)]
608#[allow(dead_code)]
609struct OpenAIChoice {
610    index: i32,
611    message: OpenAIMessage,
612    finish_reason: Option<String>,
613}
614
615#[derive(Debug, Deserialize)]
616#[allow(dead_code)]
617struct OpenAIMessage {
618    role: String,
619    content: Option<String>,
620    /// 推理模型的思维链内容(如 glm-5.2, DeepSeek-R1 等)
621    reasoning_content: Option<String>,
622    tool_calls: Option<Vec<lc_core::tools::ToolCall>>,
623}
624
625#[derive(Debug, Deserialize)]
626#[allow(dead_code)]
627struct OpenAIUsage {
628    prompt_tokens: usize,
629    completion_tokens: usize,
630    total_tokens: usize,
631}