diff --git a/Cargo.toml b/Cargo.toml index e50301f..6fa76b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,7 @@ serde_json = "1" thiserror = "2" async-trait = "0.1" tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +dotenvy = "0.15.7" diff --git a/src/lib.rs b/src/lib.rs index cb4039d..7b85201 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,18 @@ //! 提示词工程、记忆系统、工具调用、Agent 运行时等领域。 pub mod llm; + +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +static INIT: std::sync::Once = std::sync::Once::new(); + +pub fn init_tracing() { + INIT.call_once(|| { + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("agcore=info")); + tracing_subscriber::registry() + .with(fmt::layer()) + .with(filter) + .init(); + }); +} diff --git a/src/llm/cycle.rs b/src/llm/cycle.rs index 250fc73..0b52c9e 100644 --- a/src/llm/cycle.rs +++ b/src/llm/cycle.rs @@ -107,7 +107,9 @@ impl LlmCycle { let mut messages = self.messages.clone(); if let Some(sys_prompt) = &self.system_prompt - && !messages.iter().any(|m| matches!(m, OpenaiChatMessage::System { .. })) + && !messages + .iter() + .any(|m| matches!(m, OpenaiChatMessage::System { .. })) { messages.insert(0, OpenaiChatMessage::system_text(sys_prompt)); } @@ -116,7 +118,8 @@ impl LlmCycle { None } else { Some( - tools.iter() + tools + .iter() .map(|t| OpenaiTool::Function { function: t.clone(), }) @@ -134,4 +137,4 @@ impl LlmCycle { ..Default::default() } } -} \ No newline at end of file +} diff --git a/src/llm/cycle/usage.rs b/src/llm/cycle/usage.rs index 52974c8..ad10878 100644 --- a/src/llm/cycle/usage.rs +++ b/src/llm/cycle/usage.rs @@ -1 +1,3 @@ -pub use crate::llm::types::usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage, Usage as LlmUsage}; +pub use crate::llm::types::usage::{ + CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage, Usage as LlmUsage, +}; diff --git a/src/llm/provider/openai.rs b/src/llm/provider/openai.rs index fb9a605..6cd59ea 100644 --- a/src/llm/provider/openai.rs +++ b/src/llm/provider/openai.rs @@ -2,10 +2,11 @@ use std::time::Duration; use async_trait::async_trait; use reqwest::Client; +use tracing::{debug, error, info}; +use super::LlmProvider; use crate::llm::error::LlmError; use crate::llm::types::{ChatRequest, ChatResponse, OpenaiChatResponse}; -use super::LlmProvider; pub struct OpenaiProvider { http_client: Client, @@ -50,6 +51,8 @@ impl LlmProvider for OpenaiProvider { async fn chat(&self, request: ChatRequest) -> Result { let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); + info!(model = %request.model, max_tokens = request.max_tokens, temperature = request.temperature, "发送 LLM 请求"); + let response = self .http_client .post(&url) @@ -57,7 +60,10 @@ impl LlmProvider for OpenaiProvider { .json(&request) .send() .await - .map_err(Self::map_reqwest_error)?; + .map_err(|e| { + error!(error = %e, "请求失败"); + Self::map_reqwest_error(e) + })?; let status = response.status(); let status_code: u16 = status.as_u16(); @@ -71,6 +77,8 @@ impl LlmProvider for OpenaiProvider { .map(Duration::from_secs); let body_text = response.text().await.unwrap_or_default(); + error!(status = status_code, body = %body_text, "请求失败"); + return match status_code { 401 => Err(LlmError::Authentication(body_text)), 429 => Err(LlmError::RateLimit { retry_after }), @@ -91,11 +99,16 @@ impl LlmProvider for OpenaiProvider { }; } - let chat_response: OpenaiChatResponse = response - .json() - .await - .map_err(|e| LlmError::Other(format!("响应解析失败: {}", e)))?; + let body_text = response.text().await.unwrap_or_default(); + debug!(body = %body_text, "收到响应体"); + + let chat_response: OpenaiChatResponse = serde_json::from_str(&body_text).map_err(|e| { + error!(error = %e, body = %body_text, "响应解析失败"); + LlmError::Other(format!("响应解析失败: {}", e)) + })?; + + debug!(response = ?chat_response, "收到 LLM 响应"); Ok(ChatResponse::from(chat_response)) } -} \ No newline at end of file +} diff --git a/src/llm/types/message.rs b/src/llm/types/message.rs index d81a7f3..172773f 100644 --- a/src/llm/types/message.rs +++ b/src/llm/types/message.rs @@ -1,6 +1,46 @@ use crate::llm::types::shared::{AudioFormat, ImageDetail}; use crate::llm::types::tool::OpenaiToolCall; use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub enum ContentField { + String(String), + Array(Vec), +} + +impl<'de> Deserialize<'de> for ContentField { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + match value { + Value::String(s) => Ok(ContentField::String(s)), + Value::Array(arr) => { + let parts: Result, _> = + serde_json::from_value(Value::Array(arr)); + match parts { + Ok(parts) => Ok(ContentField::Array(parts)), + Err(e) => Err(serde::de::Error::custom(e)), + } + } + _ => Err(serde::de::Error::custom("content must be string or array")), + } + } +} + +impl Serialize for ContentField { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + ContentField::String(s) => s.serialize(serializer), + ContentField::Array(arr) => arr.serialize(serializer), + } + } +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageURL { @@ -50,22 +90,24 @@ pub enum OpenaiContentPart { #[serde(rename_all = "snake_case", tag = "role")] pub enum OpenaiChatMessage { Developer { - content: Vec, + content: ContentField, #[serde(skip_serializing_if = "Option::is_none")] name: Option, }, System { - content: Vec, + content: ContentField, #[serde(skip_serializing_if = "Option::is_none")] name: Option, }, User { - content: Vec, + content: ContentField, #[serde(skip_serializing_if = "Option::is_none")] name: Option, }, Assistant { - content: Vec, + content: ContentField, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_content: Option, #[serde(skip_serializing_if = "Option::is_none")] refusal: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -74,11 +116,11 @@ pub enum OpenaiChatMessage { tool_calls: Option>, }, Tool { - content: Vec, + content: ContentField, tool_call_id: String, }, Function { - content: Vec, + content: ContentField, name: String, }, } @@ -86,14 +128,15 @@ pub enum OpenaiChatMessage { impl OpenaiChatMessage { pub fn user_text>(text: S) -> Self { OpenaiChatMessage::User { - content: vec![OpenaiContentPart::Text { text: text.into() }], + content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]), name: None, } } pub fn assistant_text>(text: S) -> Self { OpenaiChatMessage::Assistant { - content: vec![OpenaiContentPart::Text { text: text.into() }], + content: ContentField::String(text.into()), + reasoning_content: None, refusal: None, name: None, tool_calls: None, @@ -102,23 +145,23 @@ impl OpenaiChatMessage { pub fn system_text>(text: S) -> Self { OpenaiChatMessage::System { - content: vec![OpenaiContentPart::Text { text: text.into() }], + content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]), name: None, } } pub fn developer_text>(text: S) -> Self { OpenaiChatMessage::Developer { - content: vec![OpenaiContentPart::Text { text: text.into() }], + content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]), name: None, } } pub fn tool_result>(tool_call_id: String, content: S) -> Self { OpenaiChatMessage::Tool { - content: vec![OpenaiContentPart::Text { + content: ContentField::Array(vec![OpenaiContentPart::Text { text: content.into(), - }], + }]), tool_call_id, } } diff --git a/src/llm/types/mod.rs b/src/llm/types/mod.rs index b3747a9..342f36d 100644 --- a/src/llm/types/mod.rs +++ b/src/llm/types/mod.rs @@ -6,16 +6,16 @@ pub mod tool; pub mod usage; pub use message::{ - FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart, + ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart, }; pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions, ToolChoice}; pub use response::{ - Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, - OpenaiChatChunk, OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation, + Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, OpenaiChatChunk, + OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation, }; pub use shared::{ - AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, - ServiceTier, StopSequence, + AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, ServiceTier, + StopSequence, }; pub use tool::{FunctionCall, OpenaiToolCall, OpenaiToolDefinition}; pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage}; @@ -34,10 +34,7 @@ impl From for ChatResponse { .first() .map(|c| c.message.clone()) .unwrap_or_else(|| OpenaiChatMessage::assistant_text("")); - let stop_reason = response - .choices - .first() - .and_then(|c| c.finish_reason); + let stop_reason = response.choices.first().and_then(|c| c.finish_reason); ChatResponse { message, usage: response.usage, @@ -50,4 +47,4 @@ pub type ChatRequest = OpenaiChatRequest; pub type Message = OpenaiChatMessage; pub type ContentBlock = OpenaiContentPart; pub type ToolDefinition = OpenaiToolDefinition; -pub type StopReason = FinishReason; \ No newline at end of file +pub type StopReason = FinishReason; diff --git a/src/llm/types/request.rs b/src/llm/types/request.rs index e706917..c2c8c01 100644 --- a/src/llm/types/request.rs +++ b/src/llm/types/request.rs @@ -1,7 +1,7 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; use crate::llm::types::shared::{ResponseFormat, ServiceTier, StopSequence}; use crate::llm::types::tool::OpenaiToolDefinition; +use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamOptions { @@ -16,12 +16,8 @@ pub enum ToolChoice { None, Auto, Required, - Named { - name: String, - }, - AllowedTools { - tool_names: Vec, - }, + Named { name: String }, + AllowedTools { tool_names: Vec }, } impl Serialize for ToolChoice { @@ -62,25 +58,36 @@ impl<'de> Deserialize<'de> for ToolChoice { "none" => Ok(ToolChoice::None), "auto" => Ok(ToolChoice::Auto), "required" => Ok(ToolChoice::Required), - _ => Err(serde::de::Error::custom(format!("unknown tool choice: {s}"))), + _ => Err(serde::de::Error::custom(format!( + "unknown tool choice: {s}" + ))), }, Value::Object(obj) => { let typ = obj.get("type").and_then(|v| v.as_str()).ok_or_else(|| { serde::de::Error::custom("missing 'type' field in tool_choice") })?; if typ == "function" { - let func = obj.get("function").and_then(|v| v.as_object()).ok_or_else(|| { - serde::de::Error::custom("missing 'function' field in tool_choice") - })?; + let func = + obj.get("function") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + serde::de::Error::custom("missing 'function' field in tool_choice") + })?; let name = func.get("name").and_then(|v| v.as_str()).ok_or_else(|| { serde::de::Error::custom("missing 'function.name' in tool_choice") })?; - Ok(ToolChoice::Named { name: name.to_string() }) + Ok(ToolChoice::Named { + name: name.to_string(), + }) } else { - Err(serde::de::Error::custom(format!("unknown tool_choice type: {typ}"))) + Err(serde::de::Error::custom(format!( + "unknown tool_choice type: {typ}" + ))) } } - _ => Err(serde::de::Error::custom("tool_choice must be a string or object")), + _ => Err(serde::de::Error::custom( + "tool_choice must be a string or object", + )), } } } @@ -88,9 +95,7 @@ impl<'de> Deserialize<'de> for ToolChoice { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] pub enum OpenaiTool { - Function { - function: OpenaiToolDefinition, - }, + Function { function: OpenaiToolDefinition }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -173,4 +178,4 @@ pub struct OpenaiChatRequest { pub extra_headers: Option, #[serde(skip_serializing_if = "Option::is_none")] pub extra_body: Option, -} \ No newline at end of file +} diff --git a/src/llm/types/response.rs b/src/llm/types/response.rs index 90011ce..d3eba1c 100644 --- a/src/llm/types/response.rs +++ b/src/llm/types/response.rs @@ -1,8 +1,8 @@ -use serde::{Deserialize, Serialize}; -use crate::llm::types::shared::{FinishReason, ServiceTier}; use crate::llm::types::message::OpenaiChatMessage; +use crate::llm::types::shared::{FinishReason, ServiceTier}; use crate::llm::types::tool::OpenaiToolCall; use crate::llm::types::usage::Usage; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenLogprob { @@ -114,4 +114,4 @@ pub struct OpenaiChatChunk { pub usage: Option, #[serde(skip_serializing_if = "Option::is_none")] pub system_fingerprint: Option, -} \ No newline at end of file +} diff --git a/src/llm/types/shared.rs b/src/llm/types/shared.rs index 210e0fc..75bb06f 100644 --- a/src/llm/types/shared.rs +++ b/src/llm/types/shared.rs @@ -75,4 +75,4 @@ pub enum ResponseFormat { #[serde(skip_serializing_if = "Option::is_none")] strict: Option, }, -} \ No newline at end of file +} diff --git a/src/llm/types/tool.rs b/src/llm/types/tool.rs index 1506361..bfcde2e 100644 --- a/src/llm/types/tool.rs +++ b/src/llm/types/tool.rs @@ -20,8 +20,5 @@ pub struct FunctionCall { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] pub enum OpenaiToolCall { - Function { - id: String, - function: FunctionCall, - }, -} \ No newline at end of file + Function { id: String, function: FunctionCall }, +} diff --git a/src/llm/types/usage.rs b/src/llm/types/usage.rs index e7121f6..534af57 100644 --- a/src/llm/types/usage.rs +++ b/src/llm/types/usage.rs @@ -72,4 +72,4 @@ impl Usage { prompt_tokens_details: None, } } -} \ No newline at end of file +}