1use 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#[derive(Clone)]
23pub struct OpenAIChat {
24 pub(crate) config: OpenAIConfig,
25 pub(crate) client: reqwest::Client,
26}
27
28impl OpenAIChat {
29 pub fn new(config: OpenAIConfig) -> Self {
31 Self {
32 config,
33 client: reqwest::Client::new(),
34 }
35 }
36
37 #[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 pub fn from_env_result() -> Result<Self, OpenAIError> {
49 let config = OpenAIConfig::from_env_result()?;
50 Ok(Self::new(config))
51 }
52
53 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 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 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 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 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 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
172pub 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 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 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 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 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 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 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 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 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#[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 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 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#[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 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}