Skip to content

Commit

Permalink
+ tool - First pass at adding Function Calling for OpenAI and Anthrop…
Browse files Browse the repository at this point in the history
…ic (rel #24)
  • Loading branch information
jeremychone committed Oct 30, 2024
1 parent 4b76a5e commit 001b124
Show file tree
Hide file tree
Showing 23 changed files with 740 additions and 177 deletions.
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ repository = "https://github.com/jeremychone/rust-genai"

[lints.rust]
unsafe_code = "forbid"
# unused = { level = "allow", priority = -1 } # For exploratory dev.
missing_docs = "warn"
unused = { level = "allow", priority = -1 } # For exploratory dev.
# missing_docs = "warn"

[dependencies]
# -- Async
Expand Down
165 changes: 141 additions & 24 deletions src/adapter/adapters/anthropic/adapter_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ use crate::adapter::support::get_api_key;
use crate::adapter::{Adapter, AdapterKind, ServiceType, WebRequestData};
use crate::chat::{
ChatOptionsSet, ChatRequest, ChatResponse, ChatRole, ChatStream, ChatStreamResponse, MessageContent, MetaUsage,
ToolCall,
};
use crate::webc::WebResponse;
use crate::Result;
use crate::{ClientConfig, ModelIden};
use crate::{Error, Result};
use reqwest::RequestBuilder;
use reqwest_eventsource::EventSource;
use serde_json::{json, Value};
Expand All @@ -18,9 +19,9 @@ const BASE_URL: &str = "https://api.anthropic.com/v1/";
const MAX_TOKENS: u32 = 1024;
const ANTRHOPIC_VERSION: &str = "2023-06-01";
const MODELS: &[&str] = &[
"claude-3-5-sonnet-20241022",
"claude-3-5-sonnet-20240620",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
];

Expand Down Expand Up @@ -53,26 +54,35 @@ impl Adapter for AnthropicAdapter {
let url = Self::get_service_url(model_iden.clone(), service_type);

// -- api_key (this Adapter requires it)
let api_key = get_api_key(model_iden, client_config)?;
let api_key = get_api_key(model_iden.clone(), client_config)?;

let headers = vec![
// headers
("x-api-key".to_string(), api_key.to_string()),
("anthropic-version".to_string(), ANTRHOPIC_VERSION.to_string()),
];

let AnthropicRequestParts { system, messages } = Self::into_anthropic_request_parts(chat_req)?;
let AnthropicRequestParts {
system,
messages,
tools,
} = Self::into_anthropic_request_parts(model_iden, chat_req)?;

// -- Build the basic payload
let mut payload = json!({
"model": model_name.to_string(),
"messages": messages,
"stream": stream
});

if let Some(system) = system {
payload.x_insert("system", system)?;
}

if let Some(tools) = tools {
payload.x_insert("/tools", tools);
}

// -- Add supported ChatOptions
if let Some(temperature) = options_set.temperature() {
payload.x_insert("temperature", temperature)?;
Expand All @@ -90,27 +100,51 @@ impl Adapter for AnthropicAdapter {

fn to_chat_response(model_iden: ModelIden, web_response: WebResponse) -> Result<ChatResponse> {
let WebResponse { mut body, .. } = web_response;
let json_content_items: Vec<Value> = body.x_take("content")?;

let mut content: Vec<String> = Vec::new();

// -- Capture the usage
let usage = body.x_take("usage").map(Self::into_usage).unwrap_or_default();

for mut item in json_content_items {
let item_text: String = item.x_take("text")?;
content.push(item_text);
// -- Capture the content
// NOTE: Anthropic support a list of content of multitypes but not the ChatResponse
// So, the strategy is to:
// - List all of the content and capture the text and tool_use
// - If there is one or more tool_use, this will take precedence and MessageContent support tool_call list
// - Otherwise, the text is concatenated
// NOTE: We need to see if the multiple content type text happens and why. If not, we can probably simplify this by just capturing the first one.
// Eventually, ChatResponse will have `content: Option<Vec<MessageContent>>` for the multi parts (with images and such)
let content_items: Vec<Value> = body.x_take("content")?;

let mut text_content: Vec<String> = Vec::new();
// Note: here tool_calls is probably the exception, so, not creating the vector if not needed
let mut tool_calls: Option<Vec<ToolCall>> = None;

for mut item in content_items {
let typ: &str = item.x_get_as("type")?;
if typ == "text" {
text_content.push(item.x_take("text")?);
} else if typ == "tool_use" {
let call_id = item.x_take::<String>("id")?;
let fn_name = item.x_take::<String>("name")?;
// if not found, will be Value::Null
let fn_arguments = item.x_take::<Value>("input").unwrap_or_default();
let tool_call = ToolCall {
call_id,
fn_name,
fn_arguments,
};
tool_calls.get_or_insert_with(Vec::new).push(tool_call);
}
}

let content = if content.is_empty() {
None
let content = if let Some(tool_calls) = tool_calls {
Some(MessageContent::from(tool_calls))
} else {
Some(content.join(""))
Some(MessageContent::from(text_content.join("\n")))
};
let content = content.map(MessageContent::from);

Ok(ChatResponse {
model_iden,
content,
model_iden,
usage,
})
}
Expand Down Expand Up @@ -153,40 +187,123 @@ impl AnthropicAdapter {

/// Takes the GenAI ChatMessages and constructs the System string and JSON Messages for Anthropic.
/// - Will push the `ChatRequest.system` and system message to `AnthropicRequestParts.system`
fn into_anthropic_request_parts(chat_req: ChatRequest) -> Result<AnthropicRequestParts> {
fn into_anthropic_request_parts(model_iden: ModelIden, chat_req: ChatRequest) -> Result<AnthropicRequestParts> {
let mut messages: Vec<Value> = Vec::new();
let mut systems: Vec<String> = Vec::new();

if let Some(system) = chat_req.system {
systems.push(system);
}

// -- Process the messages
for msg in chat_req.messages {
// Note: Will handle more types later
let MessageContent::Text(content) = msg.content;

match msg.role {
// for now, system and tool messages go to system
ChatRole::System | ChatRole::Tool => systems.push(content),
ChatRole::User => messages.push(json! ({"role": "user", "content": content})),
ChatRole::Assistant => messages.push(json! ({"role": "assistant", "content": content})),
ChatRole::System => {
if let MessageContent::Text(content) = msg.content {
systems.push(content)
}
// TODO: Needs to trace/warn that other type are not supported
}
ChatRole::User => {
if let MessageContent::Text(content) = msg.content {
messages.push(json! ({"role": "user", "content": content}))
}
// TODO: Needs to trace/warn that other type are not supported
}
ChatRole::Assistant => {
//
match msg.content {
MessageContent::Text(content) => {
messages.push(json! ({"role": "assistant", "content": content}))
}
MessageContent::ToolCalls(tool_calls) => {
let tool_calls = tool_calls
.into_iter()
.map(|tool_call| {
// see: https://docs.anthropic.com/en/docs/build-with-claude/tool-use#example-of-successful-tool-result
json!({
"type": "tool_use",
"id": tool_call.call_id,
"name": tool_call.fn_name,
"input": tool_call.fn_arguments,
})
})
.collect::<Vec<Value>>();
messages.push(json! ({
"role": "assistant",
"content": tool_calls
}));
}
// TODO: Probably need to trace/warn that this will be ignored
MessageContent::ToolResponses(_) => (),
}
}
ChatRole::Tool => {
if let MessageContent::ToolResponses(tool_responses) = msg.content {
let tool_responses = tool_responses
.into_iter()
.map(|tool_response| {
json!({
"type": "tool_result",
"content": tool_response.content,
"tool_use_id": tool_response.call_id,
})
})
.collect::<Vec<Value>>();

// FIXME: MessageContent::ToolResponse should be MessageContent::ToolResponses (even if openAI does require multi Tool message)
messages.push(json!({
"role": "user",
"content": tool_responses
}));
}
// TODO: Probably need to trace/warn that this will be ignored
}
}
}

// -- Create the Anthropic system
// NOTE: Anthropic does not have a "role": "system", just a single optional system property
let system = if !systems.is_empty() {
Some(systems.join("\n"))
} else {
None
};

Ok(AnthropicRequestParts { system, messages })
// -- Process the tools
let tools = chat_req.tools.map(|tools| {
tools
.into_iter()
.map(|tool| {
// TODO: Need to handle the error correctly
// TODO: Needs to have a custom serializer (tool should not have to match to a provider)
// NOTE: Right now, low probability, so, we just return null if cannto to value.
let mut tool_value = json!({
"name": tool.name,
"input_schema": tool.schema,
});

if let Some(description) = tool.description {
tool_value.x_insert("description", description);
}
tool_value
})
.collect::<Vec<Value>>()
});

Ok(AnthropicRequestParts {
system,
messages,
tools,
})
}
}

struct AnthropicRequestParts {
system: Option<String>,
messages: Vec<Value>,
// TODO: need to add tools
tools: Option<Vec<Value>>,
}

// endregion: --- Support
1 change: 1 addition & 0 deletions src/adapter/adapters/anthropic/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! API Documentation: https://docs.anthropic.com/en/api/messages
//! Tool Documentation: https://docs.anthropic.com/en/docs/build-with-claude/tool-use
//! Model Names: https://docs.anthropic.com/en/docs/models-overview
//! Pricing: https://www.anthropic.com/pricing#anthropic-api
Expand Down
20 changes: 15 additions & 5 deletions src/adapter/adapters/cohere/adapter_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ impl Adapter for CohereAdapter {
.map(MessageContent::from);

Ok(ChatResponse {
model_iden,
content,
model_iden,
usage,
})
}
Expand Down Expand Up @@ -185,13 +185,23 @@ impl CohereAdapter {
actual_role: last_chat_msg.role,
});
}
// Will handle more types later
let MessageContent::Text(message) = last_chat_msg.content;

// TODO: Needs to implement tool_calls
let MessageContent::Text(message) = last_chat_msg.content else {
return Err(Error::MessageContentTypeNotSupported {
model_iden,
cause: "Only MessageContent::Text supported for this model (for now)",
});
};

// -- Build
for msg in chat_req.messages {
// Note: Will handle more types later
let MessageContent::Text(content) = msg.content;
let MessageContent::Text(content) = msg.content else {
return Err(Error::MessageContentTypeNotSupported {
model_iden,
cause: "Only MessageContent::Text supported for this model (for now)",
});
};

match msg.role {
// For now, system and tool go to the system
Expand Down
11 changes: 8 additions & 3 deletions src/adapter/adapters/gemini/adapter_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ impl Adapter for GeminiAdapter {
let content = content.map(MessageContent::from);

Ok(ChatResponse {
model_iden,
content,
model_iden,
usage,
})
}
Expand Down Expand Up @@ -192,8 +192,13 @@ impl GeminiAdapter {

// -- Build
for msg in chat_req.messages {
// Note: Will handle more types later
let MessageContent::Text(content) = msg.content;
// TODO: Needs to implement tool_calls
let MessageContent::Text(content) = msg.content else {
return Err(Error::MessageContentTypeNotSupported {
model_iden,
cause: "Only MessageContent::Text supported for this model (for now)",
});
};

match msg.role {
// For now, system goes as "user" (later, we might have adapter_config.system_to_user_impl)
Expand Down
Loading

0 comments on commit 001b124

Please sign in to comment.