feat(llm): 添加 tracing 日志与 ContentField 扩展
为 OpenAI 消息类型引入 ContentField 以支持 string 和 array 两种 content 格式,新增 reasoning_content 字段;添加 tracing 日志初始化函数及请求 /响应日志;修正多处文件末尾换行与 import 顺序。
This commit is contained in:
@@ -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"
|
||||
|
||||
+15
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
+5
-2
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<ChatResponse, LlmError> {
|
||||
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,10 +99,15 @@ 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))
|
||||
}
|
||||
|
||||
+55
-12
@@ -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<OpenaiContentPart>),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentField {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<Vec<OpenaiContentPart>, _> =
|
||||
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
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<OpenaiContentPart>,
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
System {
|
||||
content: Vec<OpenaiContentPart>,
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
User {
|
||||
content: Vec<OpenaiContentPart>,
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
Assistant {
|
||||
content: Vec<OpenaiContentPart>,
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
refusal: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -74,11 +116,11 @@ pub enum OpenaiChatMessage {
|
||||
tool_calls: Option<Vec<OpenaiToolCall>>,
|
||||
},
|
||||
Tool {
|
||||
content: Vec<OpenaiContentPart>,
|
||||
content: ContentField,
|
||||
tool_call_id: String,
|
||||
},
|
||||
Function {
|
||||
content: Vec<OpenaiContentPart>,
|
||||
content: ContentField,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
@@ -86,14 +128,15 @@ pub enum OpenaiChatMessage {
|
||||
impl OpenaiChatMessage {
|
||||
pub fn user_text<S: Into<String>>(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<S: Into<String>>(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<S: Into<String>>(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<S: Into<String>>(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<S: Into<String>>(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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OpenaiChatResponse> 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,
|
||||
|
||||
+23
-18
@@ -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<String>,
|
||||
},
|
||||
Named { name: String },
|
||||
AllowedTools { tool_names: Vec<String> },
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
Function { id: String, function: FunctionCall },
|
||||
}
|
||||
Reference in New Issue
Block a user