feat: add agent structure and improve tooling for provider
This commit is contained in:
parent
e2c966d31e
commit
b341fdb368
21 changed files with 2567 additions and 443 deletions
|
|
@ -1,4 +1,8 @@
|
|||
use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -26,13 +30,76 @@ struct Message {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiChatResponse {
|
||||
struct ChatResponse {
|
||||
content: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ContentBlock {
|
||||
text: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
model: String,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system: Option<String>,
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<NativeToolSpec>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
content: Vec<NativeContentOut>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum NativeContentOut {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult { tool_use_id: String, content: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChatResponse {
|
||||
#[serde(default)]
|
||||
content: Vec<NativeContentIn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeContentIn {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
|
|
@ -62,6 +129,186 @@ impl AnthropicProvider {
|
|||
fn is_setup_token(token: &str) -> bool {
|
||||
token.starts_with("sk-ant-oat01-")
|
||||
}
|
||||
|
||||
fn apply_auth(
|
||||
&self,
|
||||
request: reqwest::RequestBuilder,
|
||||
credential: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
if Self::is_setup_token(credential) {
|
||||
request.header("Authorization", format!("Bearer {credential}"))
|
||||
} else {
|
||||
request.header("x-api-key", credential)
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
let items = tools?;
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| NativeToolSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
input_schema: tool.parameters.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_assistant_tool_call_message(content: &str) -> Option<Vec<NativeContentOut>> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
|
||||
let tool_calls = value
|
||||
.get("tool_calls")
|
||||
.and_then(|v| serde_json::from_value::<Vec<ProviderToolCall>>(v.clone()).ok())?;
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
if let Some(text) = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
{
|
||||
blocks.push(NativeContentOut::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
for call in tool_calls {
|
||||
let input = serde_json::from_str::<serde_json::Value>(&call.arguments)
|
||||
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
|
||||
blocks.push(NativeContentOut::ToolUse {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
input,
|
||||
});
|
||||
}
|
||||
Some(blocks)
|
||||
}
|
||||
|
||||
fn parse_tool_result_message(content: &str) -> Option<NativeMessage> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
|
||||
let tool_use_id = value
|
||||
.get("tool_call_id")
|
||||
.and_then(serde_json::Value::as_str)?
|
||||
.to_string();
|
||||
let result = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::ToolResult {
|
||||
tool_use_id,
|
||||
content: result,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<NativeMessage>) {
|
||||
let mut system_prompt = None;
|
||||
let mut native_messages = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
match msg.role.as_str() {
|
||||
"system" => {
|
||||
if system_prompt.is_none() {
|
||||
system_prompt = Some(msg.content.clone());
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
if let Some(blocks) = Self::parse_assistant_tool_call_message(&msg.content) {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: blocks,
|
||||
});
|
||||
} else {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
if let Some(tool_result) = Self::parse_tool_result_message(&msg.content) {
|
||||
native_messages.push(tool_result);
|
||||
} else {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(system_prompt, native_messages)
|
||||
}
|
||||
|
||||
fn parse_text_response(response: ChatResponse) -> anyhow::Result<String> {
|
||||
response
|
||||
.content
|
||||
.into_iter()
|
||||
.find(|c| c.kind == "text")
|
||||
.and_then(|c| c.text)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Anthropic"))
|
||||
}
|
||||
|
||||
fn parse_native_response(response: NativeChatResponse) -> ProviderChatResponse {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
for block in response.content {
|
||||
match block.kind.as_str() {
|
||||
"text" => {
|
||||
if let Some(text) = block.text.map(|t| t.trim().to_string()) {
|
||||
if !text.is_empty() {
|
||||
text_parts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let name = block.name.unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let arguments = block
|
||||
.input
|
||||
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
|
||||
tool_calls.push(ProviderToolCall {
|
||||
id: block.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name,
|
||||
arguments: arguments.to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderChatResponse {
|
||||
text: if text_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text_parts.join("\n"))
|
||||
},
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -72,7 +319,7 @@ impl Provider for AnthropicProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)."
|
||||
|
|
@ -97,11 +344,7 @@ impl Provider for AnthropicProvider {
|
|||
.header("content-type", "application/json")
|
||||
.json(&request);
|
||||
|
||||
if Self::is_setup_token(credential) {
|
||||
request = request.header("Authorization", format!("Bearer {credential}"));
|
||||
} else {
|
||||
request = request.header("x-api-key", credential);
|
||||
}
|
||||
request = self.apply_auth(request, credential);
|
||||
|
||||
let response = request.send().await?;
|
||||
|
||||
|
|
@ -109,14 +352,50 @@ impl Provider for AnthropicProvider {
|
|||
return Err(super::api_error("Anthropic", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let chat_response: ChatResponse = response.json().await?;
|
||||
Self::parse_text_response(chat_response)
|
||||
}
|
||||
|
||||
chat_response
|
||||
.content
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ProviderChatResponse::with_text(c.text))
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Anthropic"))
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)."
|
||||
)
|
||||
})?;
|
||||
|
||||
let (system_prompt, messages) = Self::convert_messages(request.messages);
|
||||
let native_request = NativeChatRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: 4096,
|
||||
system: system_prompt,
|
||||
messages,
|
||||
temperature,
|
||||
tools: Self::convert_tools(request.tools),
|
||||
};
|
||||
|
||||
let req = self
|
||||
.client
|
||||
.post(format!("{}/v1/messages", self.base_url))
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&native_request);
|
||||
|
||||
let response = self.apply_auth(req, credential).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("Anthropic", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
Ok(Self::parse_native_response(native_response))
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,15 +520,16 @@ mod tests {
|
|||
#[test]
|
||||
fn chat_response_deserializes() {
|
||||
let json = r#"{"content":[{"type":"text","text":"Hello there!"}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.content.len(), 1);
|
||||
assert_eq!(resp.content[0].text, "Hello there!");
|
||||
assert_eq!(resp.content[0].kind, "text");
|
||||
assert_eq!(resp.content[0].text.as_deref(), Some("Hello there!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_empty_content() {
|
||||
let json = r#"{"content":[]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.content.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -257,10 +537,10 @@ mod tests {
|
|||
fn chat_response_multiple_blocks() {
|
||||
let json =
|
||||
r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.content.len(), 2);
|
||||
assert_eq!(resp.content[0].text, "First");
|
||||
assert_eq!(resp.content[1].text, "Second");
|
||||
assert_eq!(resp.content[0].text.as_deref(), Some("First"));
|
||||
assert_eq!(resp.content[1].text.as_deref(), Some("Second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue