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 pub fn from_env_result() -> Result<Self, OpenAIError> {
39 let config = OpenAIConfig::from_env_result()?;
40 Ok(Self::new(config))
41 }
42
43 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 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 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 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 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 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
162pub 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 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 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 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 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 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 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 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 for event in events {
557 if event.is_done() {
558 break;
559 }
560 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 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#[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 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 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#[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 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}