[goose-llm] remove dependency on goose & mcp-core, add needsApproval (#2425)

This commit is contained in:
Salman Mohammed
2025-05-06 14:43:49 -04:00
committed by GitHub
parent 7fbdb5e79d
commit d4c5b30f58
23 changed files with 4104 additions and 111 deletions
Generated
+13 -2
View File
@@ -2523,15 +2523,23 @@ name = "goose-llm"
version = "1.0.22"
dependencies = [
"anyhow",
"async-trait",
"base64 0.21.7",
"chrono",
"goose",
"criterion",
"include_dir",
"mcp-core",
"minijinja",
"once_cell",
"regex",
"reqwest 0.12.12",
"serde",
"serde_json",
"smallvec",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tracing",
"url",
]
[[package]]
@@ -5561,6 +5569,9 @@ name = "smallvec"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
dependencies = [
"serde",
]
[[package]]
name = "smawk"
+24 -2
View File
@@ -8,16 +8,38 @@ repository.workspace = true
description.workspace = true
[dependencies]
goose = { path = "../goose" }
mcp-core = { path = "../mcp-core" }
tokio = { version = "1.43", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
minijinja = "2.8.0"
include_dir = "0.7.4"
once_cell = "1.20.2"
chrono = { version = "0.4.38", features = ["serde"] }
reqwest = { version = "0.12.9", features = [
"rustls-tls-native-roots",
"json",
"cookies",
"gzip",
"brotli",
"deflate",
"zstd",
"charset",
"http2",
"stream"
], default-features = false }
async-trait = "0.1"
url = "2.5"
base64 = "0.21"
regex = "1.11.1"
tracing = "0.1"
smallvec = { version = "1.13", features = ["serde"] }
[dev-dependencies]
criterion = "0.5"
tempfile = "3.15.0"
[[example]]
name = "simple"
+32 -15
View File
@@ -1,19 +1,21 @@
use std::vec;
use anyhow::Result;
use goose::message::Message;
use goose::model::ModelConfig;
use goose_llm::{completion, CompletionResponse, Extension};
use mcp_core::tool::Tool;
use goose_llm::{
completion,
types::completion::{CompletionResponse, ExtensionConfig, ToolApprovalMode, ToolConfig},
Message, ModelConfig,
};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<()> {
let provider = "databricks";
let model_name = "goose-claude-3-5-sonnet";
// let model_name = "goose-claude-3-5-sonnet"; // sequential tool calls
let model_name = "goose-gpt-4-1"; // parallel tool calls
let model_config = ModelConfig::new(model_name.to_string());
let calculator_tool = Tool::new(
let calculator_tool = ToolConfig::new(
"calculator",
"Perform basic arithmetic operations",
json!({
@@ -32,10 +34,10 @@ async fn main() -> Result<()> {
}
}
}),
None,
ToolApprovalMode::Auto,
);
let bash_tool = Tool::new(
let bash_tool = ToolConfig::new(
"bash_shell",
"Run a shell command",
json!({
@@ -48,28 +50,43 @@ async fn main() -> Result<()> {
}
}
}),
None,
ToolApprovalMode::Manual,
);
let list_dir_tool = ToolConfig::new(
"list_directory",
"List files in a directory",
json!({
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "The directory path to list files from"
}
}
}),
ToolApprovalMode::Auto,
);
let extensions = vec![
Extension::new(
ExtensionConfig::new(
"calculator_extension".to_string(),
Some("This extension provides a calculator tool.".to_string()),
vec![calculator_tool],
),
Extension::new(
ExtensionConfig::new(
"bash_extension".to_string(),
Some("This extension provides a bash shell tool.".to_string()),
vec![bash_tool],
vec![bash_tool, list_dir_tool],
),
];
let system_preamble = "You are a helpful assistant.";
for text in [
"Add 10037 + 23123",
// "Write some random bad words to end of words.txt",
// "List all json files in the current directory and then multiply the count of the files by 7",
"Add 10037 + 23123 using calculator and also run 'date -u' using bash",
"List all files in the current directory",
] {
println!("\n---------------\n");
println!("User Input: {text}");
+48 -18
View File
@@ -1,17 +1,41 @@
use std::{collections::HashMap, time::Instant};
use anyhow::Result;
use chrono::Utc;
use serde_json::Value;
use std::collections::HashMap;
use goose::message::Message;
use goose::model::ModelConfig;
use goose::providers::create;
use goose::providers::errors::ProviderError;
use crate::{
message::{Message, MessageContent},
model::ModelConfig,
prompt_template,
providers::{create, errors::ProviderError},
types::completion::{
CompletionResponse, ExtensionConfig, RuntimeMetrics, ToolApprovalMode, ToolConfig,
},
};
use std::time::Instant;
/// Set `needs_approval` on *every* tool call in the message based on approval mode.
pub fn update_needs_approval_for_tool_calls(
message: &mut Message,
tool_configs: &HashMap<String, ToolConfig>,
) {
for content in message.content.iter_mut() {
if let MessageContent::ToolRequest(req) = content {
if let Ok(call) = &mut req.tool_call {
let needs = match tool_configs.get(&call.name) {
Some(cfg) => match cfg.approval_mode {
ToolApprovalMode::Auto => false,
ToolApprovalMode::Manual => true,
ToolApprovalMode::Smart => true, // TODO: implement smart approval later
},
None => call.needs_approval, // unknown tool: leave flag unchanged
};
use crate::prompt_template;
use crate::{CompletionResponse, Extension, RuntimeMetrics};
call.set_needs_approval(needs);
}
}
}
}
/// Public API for the Goose LLM completion function
pub async fn completion(
@@ -19,7 +43,7 @@ pub async fn completion(
model_config: ModelConfig,
system_preamble: &str,
messages: &[Message],
extensions: &[Extension],
extensions: &[ExtensionConfig],
) -> Result<CompletionResponse, ProviderError> {
let start_total = Instant::now();
let provider = create(provider, model_config).unwrap();
@@ -32,11 +56,9 @@ pub async fn completion(
.collect::<Vec<_>>();
let start_provider = Instant::now();
let (response, usage) = provider.complete(&system_prompt, messages, &tools).await?;
let mut response = provider.complete(&system_prompt, messages, &tools).await?;
let total_time_ms_provider = start_provider.elapsed().as_millis();
let total_time_ms = start_total.elapsed().as_millis();
let tokens_per_second = usage.usage.total_tokens.and_then(|toks| {
let tokens_per_second = response.usage.total_tokens.and_then(|toks| {
if total_time_ms_provider > 0 {
Some(toks as f64 / (total_time_ms_provider as f64 / 1000.0))
} else {
@@ -44,15 +66,23 @@ pub async fn completion(
}
});
let runtime_metrics =
RuntimeMetrics::new(total_time_ms, total_time_ms_provider, tokens_per_second);
let tool_configs: HashMap<String, ToolConfig> = extensions
.iter()
.flat_map(|ext| ext.get_prefixed_tool_configs().into_iter())
.collect();
let result = CompletionResponse::new(response.clone(), usage.clone(), runtime_metrics);
update_needs_approval_for_tool_calls(&mut response.message, &tool_configs);
Ok(result)
let total_time_ms = start_total.elapsed().as_millis();
Ok(CompletionResponse::new(
response.message,
response.model,
response.usage,
RuntimeMetrics::new(total_time_ms, total_time_ms_provider, tokens_per_second),
))
}
fn construct_system_prompt(system_preamble: &str, extensions: &[Extension]) -> String {
fn construct_system_prompt(system_preamble: &str, extensions: &[ExtensionConfig]) -> String {
let mut context: HashMap<&str, Value> = HashMap::new();
context.insert(
+6 -2
View File
@@ -1,6 +1,10 @@
mod completion;
mod message;
mod model;
mod prompt_template;
mod types;
mod providers;
pub mod types;
pub use completion::completion;
pub use types::{CompletionResponse, Extension, RuntimeMetrics};
pub use message::Message;
pub use model::ModelConfig;
+531
View File
@@ -0,0 +1,531 @@
use std::{collections::HashSet, iter::FromIterator, ops::Deref};
/// Messages which represent the content sent back and forth to LLM provider
///
/// We use these messages in the agent code, and interfaces which interact with
/// the agent. That let's us reuse message histories across different interfaces.
///
/// The content of the messages uses MCP types to avoid additional conversions
/// when interacting with MCP servers.
use chrono::Utc;
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use crate::types::core::{Content, ImageContent, Role, TextContent, ToolCall, ToolResult};
mod tool_result_serde;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolRequest {
pub id: String,
#[serde(with = "tool_result_serde")]
pub tool_call: ToolResult<ToolCall>,
}
impl ToolRequest {
pub fn to_readable_string(&self) -> String {
match &self.tool_call {
Ok(tool_call) => {
format!(
"Tool: {}, Args: {}",
tool_call.name,
serde_json::to_string_pretty(&tool_call.arguments)
.unwrap_or_else(|_| "<<invalid json>>".to_string())
)
}
Err(e) => format!("Invalid tool call: {}", e),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolResponse {
pub id: String,
#[serde(with = "tool_result_serde")]
pub tool_result: ToolResult<Vec<Content>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThinkingContent {
pub thinking: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RedactedThinkingContent {
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// Content passed inside a message, which can be both simple content and tool content
#[serde(tag = "type", rename_all = "camelCase")]
pub enum MessageContent {
Text(TextContent),
Image(ImageContent),
ToolRequest(ToolRequest),
ToolResponse(ToolResponse),
Thinking(ThinkingContent),
RedactedThinking(RedactedThinkingContent),
}
impl MessageContent {
pub fn text<S: Into<String>>(text: S) -> Self {
MessageContent::Text(TextContent { text: text.into() })
}
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
MessageContent::Image(ImageContent {
data: data.into(),
mime_type: mime_type.into(),
})
}
pub fn tool_request<S: Into<String>>(id: S, tool_call: ToolResult<ToolCall>) -> Self {
MessageContent::ToolRequest(ToolRequest {
id: id.into(),
tool_call,
})
}
pub fn tool_response<S: Into<String>>(id: S, tool_result: ToolResult<Vec<Content>>) -> Self {
MessageContent::ToolResponse(ToolResponse {
id: id.into(),
tool_result,
})
}
pub fn thinking<S1: Into<String>, S2: Into<String>>(thinking: S1, signature: S2) -> Self {
MessageContent::Thinking(ThinkingContent {
thinking: thinking.into(),
signature: signature.into(),
})
}
pub fn redacted_thinking<S: Into<String>>(data: S) -> Self {
MessageContent::RedactedThinking(RedactedThinkingContent { data: data.into() })
}
pub fn as_tool_request(&self) -> Option<&ToolRequest> {
if let MessageContent::ToolRequest(ref tool_request) = self {
Some(tool_request)
} else {
None
}
}
pub fn as_tool_response(&self) -> Option<&ToolResponse> {
if let MessageContent::ToolResponse(ref tool_response) = self {
Some(tool_response)
} else {
None
}
}
pub fn as_tool_response_text(&self) -> Option<String> {
if let Some(tool_response) = self.as_tool_response() {
if let Ok(contents) = &tool_response.tool_result {
let texts: Vec<String> = contents
.iter()
.filter_map(|content| content.as_text().map(String::from))
.collect();
if !texts.is_empty() {
return Some(texts.join("\n"));
}
}
}
None
}
pub fn as_tool_request_id(&self) -> Option<&str> {
if let Self::ToolRequest(r) = self {
Some(&r.id)
} else {
None
}
}
pub fn as_tool_response_id(&self) -> Option<&str> {
if let Self::ToolResponse(r) = self {
Some(&r.id)
} else {
None
}
}
/// Get the text content if this is a TextContent variant
pub fn as_text(&self) -> Option<&str> {
match self {
MessageContent::Text(text) => Some(&text.text),
_ => None,
}
}
/// Get the thinking content if this is a ThinkingContent variant
pub fn as_thinking(&self) -> Option<&ThinkingContent> {
match self {
MessageContent::Thinking(thinking) => Some(thinking),
_ => None,
}
}
/// Get the redacted thinking content if this is a RedactedThinkingContent variant
pub fn as_redacted_thinking(&self) -> Option<&RedactedThinkingContent> {
match self {
MessageContent::RedactedThinking(redacted) => Some(redacted),
_ => None,
}
}
pub fn is_text(&self) -> bool {
matches!(self, Self::Text(_))
}
pub fn is_image(&self) -> bool {
matches!(self, Self::Image(_))
}
pub fn is_tool_request(&self) -> bool {
matches!(self, Self::ToolRequest(_))
}
pub fn is_tool_response(&self) -> bool {
matches!(self, Self::ToolResponse(_))
}
}
impl From<Content> for MessageContent {
fn from(content: Content) -> Self {
match content {
Content::Text(text) => MessageContent::Text(text),
Content::Image(image) => MessageContent::Image(image),
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// 2. Contents a new-type wrapper around SmallVec
// ────────────────────────────────────────────────────────────────────────────
/// Holds the heterogeneous fragments that make up one chat message.
///
/// * Up to two items are stored inline on the stack.
/// * Falls back to a heap allocation only when necessary.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(transparent)]
pub struct Contents(SmallVec<[MessageContent; 2]>);
impl Contents {
/*----------------------------------------------------------
* 1-line ergonomic helpers
*---------------------------------------------------------*/
pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, MessageContent> {
self.0.iter_mut()
}
pub fn push(&mut self, item: impl Into<MessageContent>) {
self.0.push(item.into());
}
pub fn texts(&self) -> impl Iterator<Item = &str> {
self.0.iter().filter_map(|c| c.as_text())
}
pub fn concat_text_str(&self) -> String {
self.texts().collect::<Vec<_>>().join("\n")
}
/// Returns `true` if *any* item satisfies the predicate.
pub fn any_is<P>(&self, pred: P) -> bool
where
P: FnMut(&MessageContent) -> bool,
{
self.iter().any(pred)
}
/// Returns `true` if *every* item satisfies the predicate.
pub fn all_are<P>(&self, pred: P) -> bool
where
P: FnMut(&MessageContent) -> bool,
{
self.iter().all(pred)
}
}
impl From<Vec<MessageContent>> for Contents {
fn from(v: Vec<MessageContent>) -> Self {
Contents(SmallVec::from_vec(v))
}
}
impl FromIterator<MessageContent> for Contents {
fn from_iter<I: IntoIterator<Item = MessageContent>>(iter: I) -> Self {
Contents(SmallVec::from_iter(iter))
}
}
/*--------------------------------------------------------------
* Allow &message.content to behave like a slice of fragments.
*-------------------------------------------------------------*/
impl Deref for Contents {
type Target = [MessageContent];
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// A message to or from an LLM
#[serde(rename_all = "camelCase")]
pub struct Message {
pub role: Role,
pub created: i64,
pub content: Contents,
}
impl Message {
pub fn new(role: Role) -> Self {
Self {
role,
created: Utc::now().timestamp(),
content: Contents::default(),
}
}
/// Create a new user message with the current timestamp
pub fn user() -> Self {
Self::new(Role::User)
}
/// Create a new assistant message with the current timestamp
pub fn assistant() -> Self {
Self::new(Role::Assistant)
}
/// Add any item that implements Into<MessageContent> to the message
pub fn with_content(mut self, item: impl Into<MessageContent>) -> Self {
self.content.push(item);
self
}
/// Add text content to the message
pub fn with_text<S: Into<String>>(self, text: S) -> Self {
self.with_content(MessageContent::text(text))
}
/// Add image content to the message
pub fn with_image<S: Into<String>, T: Into<String>>(self, data: S, mime_type: T) -> Self {
self.with_content(MessageContent::image(data, mime_type))
}
/// Add a tool request to the message
pub fn with_tool_request<S: Into<String>>(
self,
id: S,
tool_call: ToolResult<ToolCall>,
) -> Self {
self.with_content(MessageContent::tool_request(id, tool_call))
}
/// Add a tool response to the message
pub fn with_tool_response<S: Into<String>>(
self,
id: S,
result: ToolResult<Vec<Content>>,
) -> Self {
self.with_content(MessageContent::tool_response(id, result))
}
/// Add thinking content to the message
pub fn with_thinking<S1: Into<String>, S2: Into<String>>(
self,
thinking: S1,
signature: S2,
) -> Self {
self.with_content(MessageContent::thinking(thinking, signature))
}
/// Add redacted thinking content to the message
pub fn with_redacted_thinking<S: Into<String>>(self, data: S) -> Self {
self.with_content(MessageContent::redacted_thinking(data))
}
/// Check if the message is a tool call
pub fn contains_tool_call(&self) -> bool {
self.content.any_is(MessageContent::is_tool_request)
}
/// Check if the message is a tool response
pub fn contains_tool_response(&self) -> bool {
self.content.any_is(MessageContent::is_tool_response)
}
/// Check if the message contains only text content
pub fn has_only_text_content(&self) -> bool {
self.content.all_are(MessageContent::is_text)
}
/// Retrieves all tool `id` from ToolRequest messages
pub fn tool_request_ids(&self) -> HashSet<&str> {
self.content
.iter()
.filter_map(MessageContent::as_tool_request_id)
.collect()
}
/// Retrieves all tool `id` from ToolResponse messages
pub fn tool_response_ids(&self) -> HashSet<&str> {
self.content
.iter()
.filter_map(MessageContent::as_tool_response_id)
.collect()
}
/// Retrieves all tool `id` from the message
pub fn tool_ids(&self) -> HashSet<&str> {
self.tool_request_ids()
.into_iter()
.chain(self.tool_response_ids())
.collect()
}
}
#[cfg(test)]
mod tests {
use serde_json::{json, Value};
use super::*;
use crate::types::core::ToolError;
#[test]
fn test_message_serialization() {
let message = Message::assistant()
.with_text("Hello, I'll help you with that.")
.with_tool_request(
"tool123",
Ok(ToolCall::new("test_tool", json!({"param": "value"}))),
);
let json_str = serde_json::to_string_pretty(&message).unwrap();
println!("Serialized message: {}", json_str);
// Parse back to Value to check structure
let value: Value = serde_json::from_str(&json_str).unwrap();
// Check top-level fields
assert_eq!(value["role"], "assistant");
assert!(value["created"].is_i64());
assert!(value["content"].is_array());
// Check content items
let content = &value["content"];
// First item should be text
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "Hello, I'll help you with that.");
// Second item should be toolRequest
assert_eq!(content[1]["type"], "toolRequest");
assert_eq!(content[1]["id"], "tool123");
// Check tool_call serialization
assert_eq!(content[1]["toolCall"]["status"], "success");
assert_eq!(content[1]["toolCall"]["value"]["name"], "test_tool");
assert_eq!(
content[1]["toolCall"]["value"]["arguments"]["param"],
"value"
);
}
#[test]
fn test_error_serialization() {
let message = Message::assistant().with_tool_request(
"tool123",
Err(ToolError::ExecutionError(
"Something went wrong".to_string(),
)),
);
let json_str = serde_json::to_string_pretty(&message).unwrap();
println!("Serialized error: {}", json_str);
// Parse back to Value to check structure
let value: Value = serde_json::from_str(&json_str).unwrap();
// Check tool_call serialization with error
let tool_call = &value["content"][0]["toolCall"];
assert_eq!(tool_call["status"], "error");
assert_eq!(tool_call["error"], "Execution failed: Something went wrong");
}
#[test]
fn test_deserialization() {
// Create a JSON string with our new format
let json_str = r#"{
"role": "assistant",
"created": 1740171566,
"content": [
{
"type": "text",
"text": "I'll help you with that."
},
{
"type": "toolRequest",
"id": "tool123",
"toolCall": {
"status": "success",
"value": {
"name": "test_tool",
"arguments": {"param": "value"},
"needsApproval": false
}
}
}
]
}"#;
let message: Message = serde_json::from_str(json_str).unwrap();
assert_eq!(message.role, Role::Assistant);
assert_eq!(message.created, 1740171566);
assert_eq!(message.content.len(), 2);
// Check first content item
if let MessageContent::Text(text) = &message.content[0] {
assert_eq!(text.text, "I'll help you with that.");
} else {
panic!("Expected Text content");
}
// Check second content item
if let MessageContent::ToolRequest(req) = &message.content[1] {
assert_eq!(req.id, "tool123");
if let Ok(tool_call) = &req.tool_call {
assert_eq!(tool_call.name, "test_tool");
assert_eq!(tool_call.arguments, json!({"param": "value"}));
} else {
panic!("Expected successful tool call");
}
} else {
panic!("Expected ToolRequest content");
}
}
#[test]
fn test_message_with_text() {
let message = Message::user().with_text("Hello");
assert_eq!(message.content.concat_text_str(), "Hello");
}
#[test]
fn test_message_with_tool_request() {
let tool_call = Ok(ToolCall::new("test_tool", json!({})));
let message = Message::assistant().with_tool_request("req1", tool_call);
assert!(message.contains_tool_call());
assert!(!message.contains_tool_response());
let ids = message.tool_ids();
assert_eq!(ids.len(), 1);
assert!(ids.contains("req1"));
}
}
@@ -0,0 +1,64 @@
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
use crate::types::core::{ToolError, ToolResult};
pub fn serialize<T, S>(value: &ToolResult<T>, serializer: S) -> Result<S::Ok, S::Error>
where
T: Serialize,
S: Serializer,
{
match value {
Ok(val) => {
let mut state = serializer.serialize_struct("ToolResult", 2)?;
state.serialize_field("status", "success")?;
state.serialize_field("value", val)?;
state.end()
}
Err(err) => {
let mut state = serializer.serialize_struct("ToolResult", 2)?;
state.serialize_field("status", "error")?;
state.serialize_field("error", &err.to_string())?;
state.end()
}
}
}
// For deserialization, let's use a simpler approach that works with the format we're serializing to
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<ToolResult<T>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
// Define a helper enum to handle the two possible formats
#[derive(Deserialize)]
#[serde(untagged)]
enum ResultFormat<T> {
Success { status: String, value: T },
Error { status: String, error: String },
}
let format = ResultFormat::deserialize(deserializer)?;
match format {
ResultFormat::Success { status, value } => {
if status == "success" {
Ok(Ok(value))
} else {
Err(serde::de::Error::custom(format!(
"Expected status 'success', got '{}'",
status
)))
}
}
ResultFormat::Error { status, error } => {
if status == "error" {
Ok(Err(ToolError::ExecutionError(error)))
} else {
Err(serde::de::Error::custom(format!(
"Expected status 'error', got '{}'",
status
)))
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
use serde::{Deserialize, Serialize};
const DEFAULT_CONTEXT_LIMIT: usize = 128_000;
/// Configuration for model-specific settings and limits
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelConfig {
/// The name of the model to use
pub model_name: String,
/// Optional explicit context limit that overrides any defaults
pub context_limit: Option<usize>,
/// Optional temperature setting (0.0 - 1.0)
pub temperature: Option<f32>,
/// Optional maximum tokens to generate
pub max_tokens: Option<i32>,
}
impl ModelConfig {
/// Create a new ModelConfig with the specified model name
///
/// The context limit is set with the following precedence:
/// 1. Explicit context_limit if provided in config
/// 2. Model-specific default based on model name
/// 3. Global default (128_000) (in get_context_limit)
pub fn new(model_name: String) -> Self {
let context_limit = Self::get_model_specific_limit(&model_name);
Self {
model_name,
context_limit,
temperature: None,
max_tokens: None,
}
}
/// Get model-specific context limit based on model name
fn get_model_specific_limit(model_name: &str) -> Option<usize> {
// Implement some sensible defaults
match model_name {
// OpenAI models, https://platform.openai.com/docs/models#models-overview
name if name.contains("gpt-4o") => Some(128_000),
name if name.contains("gpt-4-turbo") => Some(128_000),
// Anthropic models, https://docs.anthropic.com/en/docs/about-claude/models
name if name.contains("claude-3") => Some(200_000),
// Meta Llama models, https://github.com/meta-llama/llama-models/tree/main?tab=readme-ov-file#llama-models-1
name if name.contains("llama3.2") => Some(128_000),
name if name.contains("llama3.3") => Some(128_000),
_ => None,
}
}
/// Set an explicit context limit
pub fn with_context_limit(mut self, limit: Option<usize>) -> Self {
// Default is None and therefore DEFAULT_CONTEXT_LIMIT, only set
// if input is Some to allow passing through with_context_limit in
// configuration cases
if limit.is_some() {
self.context_limit = limit;
}
self
}
/// Set the temperature
pub fn with_temperature(mut self, temp: Option<f32>) -> Self {
self.temperature = temp;
self
}
/// Set the max tokens
pub fn with_max_tokens(mut self, tokens: Option<i32>) -> Self {
self.max_tokens = tokens;
self
}
/// Get the context_limit for the current model
/// If none are defined, use the DEFAULT_CONTEXT_LIMIT
pub fn context_limit(&self) -> usize {
self.context_limit.unwrap_or(DEFAULT_CONTEXT_LIMIT)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_config_context_limits() {
// Test explicit limit
let config =
ModelConfig::new("claude-3-opus".to_string()).with_context_limit(Some(150_000));
assert_eq!(config.context_limit(), 150_000);
// Test model-specific defaults
let config = ModelConfig::new("claude-3-opus".to_string());
assert_eq!(config.context_limit(), 200_000);
let config = ModelConfig::new("gpt-4-turbo".to_string());
assert_eq!(config.context_limit(), 128_000);
// Test fallback to default
let config = ModelConfig::new("unknown-model".to_string());
assert_eq!(config.context_limit(), DEFAULT_CONTEXT_LIMIT);
}
#[test]
fn test_model_config_settings() {
let config = ModelConfig::new("test-model".to_string())
.with_temperature(Some(0.7))
.with_max_tokens(Some(1000))
.with_context_limit(Some(50_000));
assert_eq!(config.temperature, Some(0.7));
assert_eq!(config.max_tokens, Some(1000));
assert_eq!(config.context_limit, Some(50_000));
}
}
+5 -2
View File
@@ -1,9 +1,12 @@
use std::{
path::PathBuf,
sync::{Arc, RwLock},
};
use include_dir::{include_dir, Dir};
use minijinja::{Environment, Error as MiniJinjaError, Value as MJValue};
use once_cell::sync::Lazy;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
/// This directory will be embedded into the final binary.
/// Typically used to store "core" or "system" prompts.
+93
View File
@@ -0,0 +1,93 @@
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use super::errors::ProviderError;
use crate::{message::Message, types::core::Tool};
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Usage {
pub input_tokens: Option<i32>,
pub output_tokens: Option<i32>,
pub total_tokens: Option<i32>,
}
impl Usage {
pub fn new(
input_tokens: Option<i32>,
output_tokens: Option<i32>,
total_tokens: Option<i32>,
) -> Self {
Self {
input_tokens,
output_tokens,
total_tokens,
}
}
}
#[derive(Debug, Clone)]
pub struct ProviderCompleteResponse {
pub message: Message,
pub model: String,
pub usage: Usage,
}
impl ProviderCompleteResponse {
pub fn new(message: Message, model: String, usage: Usage) -> Self {
Self {
message,
model,
usage,
}
}
}
/// Base trait for AI providers (OpenAI, Anthropic, etc)
#[async_trait]
pub trait Provider: Send + Sync {
/// Generate the next message using the configured model and other parameters
///
/// # Arguments
/// * `system` - The system prompt that guides the model's behavior
/// * `messages` - The conversation history as a sequence of messages
/// * `tools` - Optional list of tools the model can use
///
/// # Returns
/// A tuple containing the model's response message and provider usage statistics
///
/// # Errors
/// ProviderError
/// - It's important to raise ContextLengthExceeded correctly since agent handles it
async fn complete(
&self,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<ProviderCompleteResponse, ProviderError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_usage_creation() {
let usage = Usage::new(Some(10), Some(20), Some(30));
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.output_tokens, Some(20));
assert_eq!(usage.total_tokens, Some(30));
}
#[test]
fn test_provider_complete_response_creation() {
let message = Message::user().with_text("Hello, world!");
let usage = Usage::new(Some(10), Some(20), Some(30));
let response =
ProviderCompleteResponse::new(message.clone(), "test_model".to_string(), usage.clone());
assert_eq!(response.message, message);
assert_eq!(response.model, "test_model");
assert_eq!(response.usage, usage);
}
}
@@ -0,0 +1,218 @@
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
use super::{
errors::ProviderError,
formats::databricks::{create_request, get_usage, response_to_message},
utils::{get_env, get_model, ImageFormat},
};
use crate::{
message::Message,
model::ModelConfig,
providers::{Provider, ProviderCompleteResponse, Usage},
types::core::Tool,
};
pub const DATABRICKS_DEFAULT_MODEL: &str = "databricks-meta-llama-3-3-70b-instruct";
// Databricks can passthrough to a wide range of models, we only provide the default
pub const _DATABRICKS_KNOWN_MODELS: &[&str] = &[
"databricks-meta-llama-3-3-70b-instruct",
"databricks-meta-llama-3-1-405b-instruct",
"databricks-dbrx-instruct",
"databricks-mixtral-8x7b-instruct",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DatabricksAuth {
Token(String),
}
impl DatabricksAuth {
pub fn token(token: String) -> Self {
Self::Token(token)
}
}
#[derive(Debug)]
pub struct DatabricksProvider {
client: Client,
host: String,
auth: DatabricksAuth,
model: ModelConfig,
image_format: ImageFormat,
}
impl Default for DatabricksProvider {
fn default() -> Self {
let model = ModelConfig::new(DATABRICKS_DEFAULT_MODEL.to_string());
DatabricksProvider::from_env(model).expect("Failed to initialize Databricks provider")
}
}
impl DatabricksProvider {
pub fn from_env(model: ModelConfig) -> Result<Self> {
let host = get_env("DATABRICKS_HOST")?;
let api_key = get_env("DATABRICKS_TOKEN")?;
let client = Client::builder()
.timeout(Duration::from_secs(600))
.build()?;
Ok(Self {
client,
host,
auth: DatabricksAuth::token(api_key),
model,
image_format: ImageFormat::OpenAi,
})
}
async fn ensure_auth_header(&self) -> Result<String> {
match &self.auth {
DatabricksAuth::Token(token) => Ok(format!("Bearer {}", token)),
}
}
async fn post(&self, payload: Value) -> Result<Value, ProviderError> {
let base_url = Url::parse(&self.host)
.map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?;
let path = format!("serving-endpoints/{}/invocations", self.model.model_name);
let url = base_url.join(&path).map_err(|e| {
ProviderError::RequestFailed(format!("Failed to construct endpoint URL: {e}"))
})?;
let auth_header = self.ensure_auth_header().await?;
let response = self
.client
.post(url)
.header("Authorization", auth_header)
.json(&payload)
.send()
.await?;
let status = response.status();
let payload: Option<Value> = response.json().await.ok();
match status {
StatusCode::OK => payload.ok_or_else(|| {
ProviderError::RequestFailed("Response body is not valid JSON".to_string())
}),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
Err(ProviderError::Authentication(format!(
"Authentication failed. Please ensure your API keys are valid and have the required permissions. \
Status: {}. Response: {:?}",
status, payload
)))
}
StatusCode::BAD_REQUEST => {
// Databricks provides a generic 'error' but also includes 'external_model_message' which is provider specific
// We try to extract the error message from the payload and check for phrases that indicate context length exceeded
let payload_str = serde_json::to_string(&payload)
.unwrap_or_default()
.to_lowercase();
let check_phrases = [
"too long",
"context length",
"context_length_exceeded",
"reduce the length",
"token count",
"exceeds",
];
if check_phrases.iter().any(|c| payload_str.contains(c)) {
return Err(ProviderError::ContextLengthExceeded(payload_str));
}
let mut error_msg = "Unknown error".to_string();
if let Some(payload) = &payload {
// try to convert message to string, if that fails use external_model_message
error_msg = payload
.get("message")
.and_then(|m| m.as_str())
.or_else(|| {
payload
.get("external_model_message")
.and_then(|ext| ext.get("message"))
.and_then(|m| m.as_str())
})
.unwrap_or("Unknown error")
.to_string();
}
tracing::debug!(
"{}",
format!(
"Provider request failed with status: {}. Payload: {:?}",
status, payload
)
);
Err(ProviderError::RequestFailed(format!(
"Request failed with status: {}. Message: {}",
status, error_msg
)))
}
StatusCode::TOO_MANY_REQUESTS => {
Err(ProviderError::RateLimitExceeded(format!("{:?}", payload)))
}
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => {
Err(ProviderError::ServerError(format!("{:?}", payload)))
}
_ => {
tracing::debug!(
"{}",
format!(
"Provider request failed with status: {}. Payload: {:?}",
status, payload
)
);
Err(ProviderError::RequestFailed(format!(
"Request failed with status: {}",
status
)))
}
}
}
}
#[async_trait]
impl Provider for DatabricksProvider {
#[tracing::instrument(
skip(self, system, messages, tools),
fields(model_config, input, output, input_tokens, output_tokens, total_tokens)
)]
async fn complete(
&self,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<ProviderCompleteResponse, ProviderError> {
let mut payload = create_request(&self.model, system, messages, tools, &self.image_format)?;
// Remove the model key which is part of the url with databricks
payload
.as_object_mut()
.expect("payload should have model key")
.remove("model");
let response = self.post(payload.clone()).await?;
// Parse response
let message = response_to_message(response.clone())?;
let usage = match get_usage(&response) {
Ok(usage) => usage,
Err(ProviderError::UsageError(e)) => {
tracing::debug!("Failed to get usage data: {}", e);
Usage::default()
}
Err(e) => return Err(e),
};
let model = get_model(&response);
super::utils::emit_debug_trace(&self.model, &payload, &response, &usage);
Ok(ProviderCompleteResponse::new(message, model, usage))
}
}
+141
View File
@@ -0,0 +1,141 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ProviderError {
#[error("Authentication error: {0}")]
Authentication(String),
#[error("Context length exceeded: {0}")]
ContextLengthExceeded(String),
#[error("Rate limit exceeded: {0}")]
RateLimitExceeded(String),
#[error("Server error: {0}")]
ServerError(String),
#[error("Request failed: {0}")]
RequestFailed(String),
#[error("Execution error: {0}")]
ExecutionError(String),
#[error("Usage data error: {0}")]
UsageError(String),
}
impl From<anyhow::Error> for ProviderError {
fn from(error: anyhow::Error) -> Self {
ProviderError::ExecutionError(error.to_string())
}
}
impl From<reqwest::Error> for ProviderError {
fn from(error: reqwest::Error) -> Self {
ProviderError::ExecutionError(error.to_string())
}
}
#[derive(serde::Deserialize, Debug)]
pub struct OpenAIError {
#[serde(deserialize_with = "code_as_string")]
pub code: Option<String>,
pub message: Option<String>,
#[serde(rename = "type")]
pub error_type: Option<String>,
}
fn code_as_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use std::fmt;
use serde::de::{self, Visitor};
struct CodeVisitor;
impl<'de> Visitor<'de> for CodeVisitor {
type Value = Option<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string, a number, null, or none for the code field")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Some(value.to_string()))
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Some(value.to_string()))
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(CodeVisitor)
}
}
deserializer.deserialize_option(CodeVisitor)
}
impl OpenAIError {
pub fn is_context_length_exceeded(&self) -> bool {
if let Some(code) = &self.code {
code == "context_length_exceeded" || code == "string_above_max_length"
} else {
false
}
}
}
impl std::fmt::Display for OpenAIError {
/// Format the error for display.
/// E.g. {"message": "Invalid API key", "code": "invalid_api_key", "type": "client_error"}
/// would be formatted as "Invalid API key (code: invalid_api_key, type: client_error)"
/// and {"message": "Foo"} as just "Foo", etc.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(message) = &self.message {
write!(f, "{}", message)?;
}
let mut in_parenthesis = false;
if let Some(code) = &self.code {
write!(f, " (code: {}", code)?;
in_parenthesis = true;
}
if let Some(typ) = &self.error_type {
if in_parenthesis {
write!(f, ", type: {}", typ)?;
} else {
write!(f, " (type: {}", typ)?;
in_parenthesis = true;
}
}
if in_parenthesis {
write!(f, ")")?;
}
Ok(())
}
}
+15
View File
@@ -0,0 +1,15 @@
use std::sync::Arc;
use anyhow::Result;
use super::{base::Provider, databricks::DatabricksProvider, openai::OpenAiProvider};
use crate::model::ModelConfig;
pub fn create(name: &str, model: ModelConfig) -> Result<Arc<dyn Provider>> {
// We use Arc instead of Box to be able to clone for multiple async tasks
match name {
"openai" => Ok(Arc::new(OpenAiProvider::from_env(model)?)),
"databricks" => Ok(Arc::new(DatabricksProvider::from_env(model)?)),
_ => Err(anyhow::anyhow!("Unknown provider: {}", name)),
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
pub mod databricks;
pub mod openai;
@@ -0,0 +1,895 @@
use anyhow::{anyhow, Error};
use serde_json::{json, Value};
use crate::{
message::{Message, MessageContent},
model::ModelConfig,
providers::{
base::Usage,
errors::ProviderError,
utils::{
convert_image, detect_image_path, is_valid_function_name, load_image_file,
sanitize_function_name, ImageFormat,
},
},
types::core::{Content, Role, Tool, ToolCall, ToolError},
};
/// Convert internal Message format to OpenAI's API message specification
/// some openai compatible endpoints use the anthropic image spec at the content level
/// even though the message structure is otherwise following openai, the enum switches this
pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<Value> {
let mut messages_spec = Vec::new();
for message in messages {
let mut converted = json!({
"role": message.role
});
let mut output = Vec::new();
for content in message.content.iter() {
match content {
MessageContent::Text(text) => {
if !text.text.is_empty() {
// Check for image paths in the text
if let Some(image_path) = detect_image_path(&text.text) {
// Try to load and convert the image
if let Ok(image) = load_image_file(image_path) {
converted["content"] = json!([
{"type": "text", "text": text.text},
convert_image(&image, image_format)
]);
} else {
// If image loading fails, just use the text
converted["content"] = json!(text.text);
}
} else {
converted["content"] = json!(text.text);
}
}
}
MessageContent::Thinking(_) => {
// Thinking blocks are not directly used in OpenAI format
continue;
}
MessageContent::RedactedThinking(_) => {
// Redacted thinking blocks are not directly used in OpenAI format
continue;
}
MessageContent::ToolRequest(request) => match &request.tool_call {
Ok(tool_call) => {
let sanitized_name = sanitize_function_name(&tool_call.name);
let tool_calls = converted
.as_object_mut()
.unwrap()
.entry("tool_calls")
.or_insert(json!([]));
tool_calls.as_array_mut().unwrap().push(json!({
"id": request.id,
"type": "function",
"function": {
"name": sanitized_name,
"arguments": tool_call.arguments.to_string(),
}
}));
}
Err(e) => {
output.push(json!({
"role": "tool",
"content": format!("Error: {}", e),
"tool_call_id": request.id
}));
}
},
MessageContent::ToolResponse(response) => {
match &response.tool_result {
Ok(contents) => {
// Process all content, replacing images with placeholder text
let mut tool_content = Vec::new();
let mut image_messages = Vec::new();
for content in contents {
match content {
Content::Image(image) => {
// Add placeholder text in the tool response
tool_content.push(Content::text("This tool result included an image that is uploaded in the next message."));
// Create a separate image message
image_messages.push(json!({
"role": "user",
"content": [convert_image(image, image_format)]
}));
}
_ => {
tool_content.push(content.clone());
}
}
}
let tool_response_content: Value = json!(tool_content
.iter()
.map(|content| match content {
Content::Text(text) => text.text.clone(),
_ => String::new(),
})
.collect::<Vec<String>>()
.join(" "));
// First add the tool response with all content
output.push(json!({
"role": "tool",
"content": tool_response_content,
"tool_call_id": response.id
}));
// Then add any image messages that need to follow
output.extend(image_messages);
}
Err(e) => {
// A tool result error is shown as output so the model can interpret the error message
output.push(json!({
"role": "tool",
"content": format!("The tool call returned the following error:\n{}", e),
"tool_call_id": response.id
}));
}
}
}
MessageContent::Image(image) => {
// Handle direct image content
converted["content"] = json!([convert_image(image, image_format)]);
}
}
}
if converted.get("content").is_some() || converted.get("tool_calls").is_some() {
output.insert(0, converted);
}
messages_spec.extend(output);
}
messages_spec
}
/// Convert internal Tool format to OpenAI's API tool specification
pub fn format_tools(tools: &[Tool]) -> anyhow::Result<Vec<Value>> {
let mut tool_names = std::collections::HashSet::new();
let mut result = Vec::new();
for tool in tools {
if !tool_names.insert(&tool.name) {
return Err(anyhow!("Duplicate tool name: {}", tool.name));
}
let mut description = tool.description.clone();
description.truncate(1024);
// OpenAI's tool description max str len is 1024
result.push(json!({
"type": "function",
"function": {
"name": tool.name,
"description": description,
"parameters": tool.input_schema,
}
}));
}
Ok(result)
}
/// Convert OpenAI's API response to internal Message format
pub fn response_to_message(response: Value) -> anyhow::Result<Message> {
let original = response["choices"][0]["message"].clone();
let mut content = Vec::new();
if let Some(text) = original.get("content") {
if let Some(text_str) = text.as_str() {
content.push(MessageContent::text(text_str));
}
}
if let Some(tool_calls) = original.get("tool_calls") {
if let Some(tool_calls_array) = tool_calls.as_array() {
for tool_call in tool_calls_array {
let id = tool_call["id"].as_str().unwrap_or_default().to_string();
let function_name = tool_call["function"]["name"]
.as_str()
.unwrap_or_default()
.to_string();
let mut arguments = tool_call["function"]["arguments"]
.as_str()
.unwrap_or_default()
.to_string();
// If arguments is empty, we will have invalid json parsing error later.
if arguments.is_empty() {
arguments = "{}".to_string();
}
if !is_valid_function_name(&function_name) {
let error = ToolError::NotFound(format!(
"The provided function name '{}' had invalid characters, it must match this regex [a-zA-Z0-9_-]+",
function_name
));
content.push(MessageContent::tool_request(id, Err(error)));
} else {
match serde_json::from_str::<Value>(&arguments) {
Ok(params) => {
content.push(MessageContent::tool_request(
id,
Ok(ToolCall::new(&function_name, params)),
));
}
Err(e) => {
let error = ToolError::InvalidParameters(format!(
"Could not interpret tool use parameters for id {}: {}",
id, e
));
content.push(MessageContent::tool_request(id, Err(error)));
}
}
}
}
}
}
Ok(Message {
role: Role::Assistant,
created: chrono::Utc::now().timestamp(),
content: content.into(),
})
}
pub fn get_usage(data: &Value) -> Result<Usage, ProviderError> {
let usage = data
.get("usage")
.ok_or_else(|| ProviderError::UsageError("No usage data in response".to_string()))?;
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
let total_tokens = usage
.get("total_tokens")
.and_then(|v| v.as_i64())
.map(|v| v as i32)
.or_else(|| match (input_tokens, output_tokens) {
(Some(input), Some(output)) => Some(input + output),
_ => None,
});
Ok(Usage::new(input_tokens, output_tokens, total_tokens))
}
/// Validates and fixes tool schemas to ensure they have proper parameter structure.
/// If parameters exist, ensures they have properties and required fields, or removes parameters entirely.
pub fn validate_tool_schemas(tools: &mut [Value]) {
for tool in tools.iter_mut() {
if let Some(function) = tool.get_mut("function") {
if let Some(parameters) = function.get_mut("parameters") {
if parameters.is_object() {
ensure_valid_json_schema(parameters);
}
}
}
}
}
/// Ensures that the given JSON value follows the expected JSON Schema structure.
fn ensure_valid_json_schema(schema: &mut Value) {
if let Some(params_obj) = schema.as_object_mut() {
// Check if this is meant to be an object type schema
let is_object_type = params_obj
.get("type")
.and_then(|t| t.as_str())
.is_none_or(|t| t == "object"); // Default to true if no type is specified
// Only apply full schema validation to object types
if is_object_type {
// Ensure required fields exist with default values
params_obj.entry("properties").or_insert_with(|| json!({}));
params_obj.entry("required").or_insert_with(|| json!([]));
params_obj.entry("type").or_insert_with(|| json!("object"));
// Recursively validate properties if it exists
if let Some(properties) = params_obj.get_mut("properties") {
if let Some(properties_obj) = properties.as_object_mut() {
for (_key, prop) in properties_obj.iter_mut() {
if prop.is_object()
&& prop.get("type").and_then(|t| t.as_str()) == Some("object")
{
ensure_valid_json_schema(prop);
}
}
}
}
}
}
}
pub fn create_request(
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
image_format: &ImageFormat,
) -> anyhow::Result<Value, Error> {
if model_config.model_name.starts_with("o1-mini") {
return Err(anyhow!(
"o1-mini model is not currently supported since Goose uses tool calling and o1-mini does not support it. Please use o1 or o3 models instead."
));
}
let is_ox_model = model_config.model_name.starts_with("o");
// Only extract reasoning effort for O1/O3 models
let (model_name, reasoning_effort) = if is_ox_model {
let parts: Vec<&str> = model_config.model_name.split('-').collect();
let last_part = parts.last().unwrap();
match *last_part {
"low" | "medium" | "high" => {
let base_name = parts[..parts.len() - 1].join("-");
(base_name, Some(last_part.to_string()))
}
_ => (
model_config.model_name.to_string(),
Some("medium".to_string()),
),
}
} else {
// For non-O family models, use the model name as is and no reasoning effort
(model_config.model_name.to_string(), None)
};
let system_message = json!({
"role": if is_ox_model { "developer" } else { "system" },
"content": system
});
let messages_spec = format_messages(messages, image_format);
let mut tools_spec = if !tools.is_empty() {
format_tools(tools)?
} else {
vec![]
};
// Validate tool schemas
validate_tool_schemas(&mut tools_spec);
let mut messages_array = vec![system_message];
messages_array.extend(messages_spec);
let mut payload = json!({
"model": model_name,
"messages": messages_array
});
if let Some(effort) = reasoning_effort {
payload
.as_object_mut()
.unwrap()
.insert("reasoning_effort".to_string(), json!(effort));
}
if !tools_spec.is_empty() {
payload
.as_object_mut()
.unwrap()
.insert("tools".to_string(), json!(tools_spec));
}
// o1, o3 models currently don't support temperature
if !is_ox_model {
if let Some(temp) = model_config.temperature {
payload
.as_object_mut()
.unwrap()
.insert("temperature".to_string(), json!(temp));
}
}
// o1 models use max_completion_tokens instead of max_tokens
if let Some(tokens) = model_config.max_tokens {
let key = if is_ox_model {
"max_completion_tokens"
} else {
"max_tokens"
};
payload
.as_object_mut()
.unwrap()
.insert(key.to_string(), json!(tokens));
}
Ok(payload)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::types::core::Content;
#[test]
fn test_validate_tool_schemas() {
// Test case 1: Empty parameters object
// Input JSON with an incomplete parameters object
let mut actual = vec![json!({
"type": "function",
"function": {
"name": "test_func",
"description": "test description",
"parameters": {
"type": "object"
}
}
})];
// Run the function to validate and update schemas
validate_tool_schemas(&mut actual);
// Expected JSON after validation
let expected = vec![json!({
"type": "function",
"function": {
"name": "test_func",
"description": "test description",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
})];
// Compare entire JSON structures instead of individual fields
assert_eq!(actual, expected);
// Test case 2: Missing type field
let mut tools = vec![json!({
"type": "function",
"function": {
"name": "test_func",
"description": "test description",
"parameters": {
"properties": {}
}
}
})];
validate_tool_schemas(&mut tools);
let params = tools[0]["function"]["parameters"].as_object().unwrap();
assert_eq!(params["type"], "object");
// Test case 3: Complete valid schema should remain unchanged
let original_schema = json!({
"type": "function",
"function": {
"name": "test_func",
"description": "test description",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and country"
}
},
"required": ["location"]
}
}
});
let mut tools = vec![original_schema.clone()];
validate_tool_schemas(&mut tools);
assert_eq!(tools[0], original_schema);
}
const OPENAI_TOOL_USE_RESPONSE: &str = r#"{
"choices": [{
"role": "assistant",
"message": {
"tool_calls": [{
"id": "1",
"function": {
"name": "example_fn",
"arguments": "{\"param\": \"value\"}"
}
}]
}
}],
"usage": {
"input_tokens": 10,
"output_tokens": 25,
"total_tokens": 35
}
}"#;
#[test]
fn test_format_messages() -> anyhow::Result<()> {
let message = Message::user().with_text("Hello");
let spec = format_messages(&[message], &ImageFormat::OpenAi);
assert_eq!(spec.len(), 1);
assert_eq!(spec[0]["role"], "user");
assert_eq!(spec[0]["content"], "Hello");
Ok(())
}
#[test]
fn test_format_tools() -> anyhow::Result<()> {
let tool = Tool::new(
"test_tool",
"A test tool",
json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Test parameter"
}
},
"required": ["input"]
}),
);
let spec = format_tools(&[tool])?;
assert_eq!(spec.len(), 1);
assert_eq!(spec[0]["type"], "function");
assert_eq!(spec[0]["function"]["name"], "test_tool");
Ok(())
}
#[test]
fn test_format_messages_complex() -> anyhow::Result<()> {
let mut messages = vec![
Message::assistant().with_text("Hello!"),
Message::user().with_text("How are you?"),
Message::assistant().with_tool_request(
"tool1",
Ok(ToolCall::new("example", json!({"param1": "value1"}))),
),
];
// Get the ID from the tool request to use in the response
let tool_id = if let MessageContent::ToolRequest(request) = &messages[2].content[0] {
request.id.clone()
} else {
panic!("should be tool request");
};
messages
.push(Message::user().with_tool_response(tool_id, Ok(vec![Content::text("Result")])));
let spec = format_messages(&messages, &ImageFormat::OpenAi);
assert_eq!(spec.len(), 4);
assert_eq!(spec[0]["role"], "assistant");
assert_eq!(spec[0]["content"], "Hello!");
assert_eq!(spec[1]["role"], "user");
assert_eq!(spec[1]["content"], "How are you?");
assert_eq!(spec[2]["role"], "assistant");
assert!(spec[2]["tool_calls"].is_array());
assert_eq!(spec[3]["role"], "tool");
assert_eq!(spec[3]["content"], "Result");
assert_eq!(spec[3]["tool_call_id"], spec[2]["tool_calls"][0]["id"]);
Ok(())
}
#[test]
fn test_format_messages_multiple_content() -> anyhow::Result<()> {
let mut messages = vec![Message::assistant().with_tool_request(
"tool1",
Ok(ToolCall::new("example", json!({"param1": "value1"}))),
)];
// Get the ID from the tool request to use in the response
let tool_id = if let MessageContent::ToolRequest(request) = &messages[0].content[0] {
request.id.clone()
} else {
panic!("should be tool request");
};
messages
.push(Message::user().with_tool_response(tool_id, Ok(vec![Content::text("Result")])));
let spec = format_messages(&messages, &ImageFormat::OpenAi);
assert_eq!(spec.len(), 2);
assert_eq!(spec[0]["role"], "assistant");
assert!(spec[0]["tool_calls"].is_array());
assert_eq!(spec[1]["role"], "tool");
assert_eq!(spec[1]["content"], "Result");
assert_eq!(spec[1]["tool_call_id"], spec[0]["tool_calls"][0]["id"]);
Ok(())
}
#[test]
fn test_format_tools_duplicate() -> anyhow::Result<()> {
let tool1 = Tool::new(
"test_tool",
"Test tool",
json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Test parameter"
}
},
"required": ["input"]
}),
);
let tool2 = Tool::new(
"test_tool",
"Test tool",
json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Test parameter"
}
},
"required": ["input"]
}),
);
let result = format_tools(&[tool1, tool2]);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Duplicate tool name"));
Ok(())
}
#[test]
fn test_format_tools_empty() -> anyhow::Result<()> {
let spec = format_tools(&[])?;
assert!(spec.is_empty());
Ok(())
}
#[test]
fn test_format_messages_with_image_path() -> anyhow::Result<()> {
// Create a temporary PNG file with valid PNG magic numbers
let temp_dir = tempfile::tempdir()?;
let png_path = temp_dir.path().join("test.png");
let png_data = [
0x89, 0x50, 0x4E, 0x47, // PNG magic number
0x0D, 0x0A, 0x1A, 0x0A, // PNG header
0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data
];
std::fs::write(&png_path, &png_data)?;
let png_path_str = png_path.to_str().unwrap();
// Create message with image path
let message = Message::user().with_text(format!("Here is an image: {}", png_path_str));
let spec = format_messages(&[message], &ImageFormat::OpenAi);
assert_eq!(spec.len(), 1);
assert_eq!(spec[0]["role"], "user");
// Content should be an array with text and image
let content = spec[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert!(content[0]["text"].as_str().unwrap().contains(png_path_str));
assert_eq!(content[1]["type"], "image_url");
assert!(content[1]["image_url"]["url"]
.as_str()
.unwrap()
.starts_with("data:image/png;base64,"));
Ok(())
}
#[test]
fn test_response_to_message_text() -> anyhow::Result<()> {
let response = json!({
"choices": [{
"role": "assistant",
"message": {
"content": "Hello from John Cena!"
}
}],
"usage": {
"input_tokens": 10,
"output_tokens": 25,
"total_tokens": 35
}
});
let message = response_to_message(response)?;
assert_eq!(message.content.len(), 1);
if let MessageContent::Text(text) = &message.content[0] {
assert_eq!(text.text, "Hello from John Cena!");
} else {
panic!("Expected Text content");
}
assert!(matches!(message.role, Role::Assistant));
Ok(())
}
#[test]
fn test_response_to_message_valid_toolrequest() -> anyhow::Result<()> {
let response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?;
let message = response_to_message(response)?;
assert_eq!(message.content.len(), 1);
if let MessageContent::ToolRequest(request) = &message.content[0] {
let tool_call = request.tool_call.as_ref().unwrap();
assert_eq!(tool_call.name, "example_fn");
assert_eq!(tool_call.arguments, json!({"param": "value"}));
} else {
panic!("Expected ToolRequest content");
}
Ok(())
}
#[test]
fn test_response_to_message_invalid_func_name() -> anyhow::Result<()> {
let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?;
response["choices"][0]["message"]["tool_calls"][0]["function"]["name"] =
json!("invalid fn");
let message = response_to_message(response)?;
if let MessageContent::ToolRequest(request) = &message.content[0] {
match &request.tool_call {
Err(ToolError::NotFound(msg)) => {
assert!(msg.starts_with("The provided function name"));
}
_ => panic!("Expected ToolNotFound error"),
}
} else {
panic!("Expected ToolRequest content");
}
Ok(())
}
#[test]
fn test_response_to_message_json_decode_error() -> anyhow::Result<()> {
let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?;
response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] =
json!("invalid json {");
let message = response_to_message(response)?;
if let MessageContent::ToolRequest(request) = &message.content[0] {
match &request.tool_call {
Err(ToolError::InvalidParameters(msg)) => {
assert!(msg.starts_with("Could not interpret tool use parameters"));
}
_ => panic!("Expected InvalidParameters error"),
}
} else {
panic!("Expected ToolRequest content");
}
Ok(())
}
#[test]
fn test_response_to_message_empty_argument() -> anyhow::Result<()> {
let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?;
response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] =
serde_json::Value::String("".to_string());
let message = response_to_message(response)?;
if let MessageContent::ToolRequest(request) = &message.content[0] {
let tool_call = request.tool_call.as_ref().unwrap();
assert_eq!(tool_call.name, "example_fn");
assert_eq!(tool_call.arguments, json!({}));
} else {
panic!("Expected ToolRequest content");
}
Ok(())
}
#[test]
fn test_create_request_gpt_4o() -> anyhow::Result<()> {
// Test default medium reasoning effort for O3 model
let model_config = ModelConfig {
model_name: "gpt-4o".to_string(),
context_limit: Some(4096),
temperature: None,
max_tokens: Some(1024),
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
let obj = request.as_object().unwrap();
let expected = json!({
"model": "gpt-4o",
"messages": [
{
"role": "system",
"content": "system"
}
],
"max_tokens": 1024
});
for (key, value) in expected.as_object().unwrap() {
assert_eq!(obj.get(key).unwrap(), value);
}
Ok(())
}
#[test]
fn test_create_request_o1_default() -> anyhow::Result<()> {
// Test default medium reasoning effort for O1 model
let model_config = ModelConfig {
model_name: "o1".to_string(),
context_limit: Some(4096),
temperature: None,
max_tokens: Some(1024),
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
let obj = request.as_object().unwrap();
let expected = json!({
"model": "o1",
"messages": [
{
"role": "developer",
"content": "system"
}
],
"reasoning_effort": "medium",
"max_completion_tokens": 1024
});
for (key, value) in expected.as_object().unwrap() {
assert_eq!(obj.get(key).unwrap(), value);
}
Ok(())
}
#[test]
fn test_create_request_o3_custom_reasoning_effort() -> anyhow::Result<()> {
// Test custom reasoning effort for O3 model
let model_config = ModelConfig {
model_name: "o3-mini-high".to_string(),
context_limit: Some(4096),
temperature: None,
max_tokens: Some(1024),
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
let obj = request.as_object().unwrap();
let expected = json!({
"model": "o3-mini",
"messages": [
{
"role": "developer",
"content": "system"
}
],
"reasoning_effort": "high",
"max_completion_tokens": 1024
});
for (key, value) in expected.as_object().unwrap() {
assert_eq!(obj.get(key).unwrap(), value);
}
Ok(())
}
}
+10
View File
@@ -0,0 +1,10 @@
pub mod base;
pub mod databricks;
pub mod errors;
mod factory;
pub mod formats;
pub mod openai;
pub mod utils;
pub use base::{Provider, ProviderCompleteResponse, Usage};
pub use factory::create;
+160
View File
@@ -0,0 +1,160 @@
use std::{collections::HashMap, time::Duration};
use anyhow::Result;
use async_trait::async_trait;
use reqwest::Client;
use serde_json::Value;
use super::{
errors::ProviderError,
formats::openai::{create_request, get_usage, response_to_message},
utils::{emit_debug_trace, get_env, get_model, handle_response_openai_compat, ImageFormat},
};
use crate::{
message::Message,
model::ModelConfig,
providers::{Provider, ProviderCompleteResponse, Usage},
types::core::Tool,
};
pub const OPEN_AI_DEFAULT_MODEL: &str = "gpt-4o";
pub const _OPEN_AI_KNOWN_MODELS: &[&str] = &[
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-3.5-turbo",
"o1",
"o3",
"o4-mini",
];
#[derive(Debug)]
pub struct OpenAiProvider {
client: Client,
host: String,
base_path: String,
api_key: String,
organization: Option<String>,
project: Option<String>,
model: ModelConfig,
custom_headers: Option<HashMap<String, String>>,
}
impl Default for OpenAiProvider {
fn default() -> Self {
let model = ModelConfig::new(OPEN_AI_DEFAULT_MODEL.to_string());
OpenAiProvider::from_env(model).expect("Failed to initialize OpenAI provider")
}
}
impl OpenAiProvider {
pub fn from_env(model: ModelConfig) -> Result<Self> {
let api_key: String = get_env("OPENAI_API_KEY")?;
let host: String =
get_env("OPENAI_HOST").unwrap_or_else(|_| "https://api.openai.com".to_string());
let base_path: String =
get_env("OPENAI_BASE_PATH").unwrap_or_else(|_| "v1/chat/completions".to_string());
let organization: Option<String> = get_env("OPENAI_ORGANIZATION").ok();
let project: Option<String> = get_env("OPENAI_PROJECT").ok();
let custom_headers: Option<HashMap<String, String>> = get_env("OPENAI_CUSTOM_HEADERS")
.or_else(|_| get_env("OPENAI_CUSTOM_HEADERS"))
.ok()
.map(parse_custom_headers);
// parse get_env("OPENAI_TIMEOUT") to u64 or set default to 600
let timeout_secs = get_env("OPENAI_TIMEOUT")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(600);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()?;
Ok(Self {
client,
host,
base_path,
api_key,
organization,
project,
model,
custom_headers,
})
}
async fn post(&self, payload: Value) -> Result<Value, ProviderError> {
let base_url = url::Url::parse(&self.host)
.map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?;
let url = base_url.join(&self.base_path).map_err(|e| {
ProviderError::RequestFailed(format!("Failed to construct endpoint URL: {e}"))
})?;
let mut request = self
.client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key));
// Add organization header if present
if let Some(org) = &self.organization {
request = request.header("OpenAI-Organization", org);
}
// Add project header if present
if let Some(project) = &self.project {
request = request.header("OpenAI-Project", project);
}
if let Some(custom_headers) = &self.custom_headers {
for (key, value) in custom_headers {
request = request.header(key, value);
}
}
let response = request.json(&payload).send().await?;
handle_response_openai_compat(response).await
}
}
#[async_trait]
impl Provider for OpenAiProvider {
#[tracing::instrument(
skip(self, system, messages, tools),
fields(model_config, input, output, input_tokens, output_tokens, total_tokens)
)]
async fn complete(
&self,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<ProviderCompleteResponse, ProviderError> {
let payload = create_request(&self.model, system, messages, tools, &ImageFormat::OpenAi)?;
// Make request
let response = self.post(payload.clone()).await?;
// Parse response
let message = response_to_message(response.clone())?;
let usage = match get_usage(&response) {
Ok(usage) => usage,
Err(ProviderError::UsageError(e)) => {
tracing::debug!("Failed to get usage data: {}", e);
Usage::default()
}
Err(e) => return Err(e),
};
let model = get_model(&response);
emit_debug_trace(&self.model, &payload, &response, &usage);
Ok(ProviderCompleteResponse::new(message, model, usage))
}
}
fn parse_custom_headers(s: String) -> HashMap<String, String> {
s.split(',')
.filter_map(|header| {
let mut parts = header.splitn(2, '=');
let key = parts.next().map(|s| s.trim().to_string())?;
let value = parts.next().map(|s| s.trim().to_string())?;
Some((key, value))
})
.collect()
}
+347
View File
@@ -0,0 +1,347 @@
use std::{env, io::Read, path::Path};
use anyhow::Result;
use base64::Engine;
use regex::Regex;
use reqwest::{Response, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{from_value, json, Value};
use super::base::Usage;
use crate::{
model::ModelConfig,
providers::errors::{OpenAIError, ProviderError},
types::core::ImageContent,
};
#[derive(serde::Deserialize)]
struct OpenAIErrorResponse {
error: OpenAIError,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum ImageFormat {
OpenAi,
Anthropic,
}
/// Convert an image content into an image json based on format
pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value {
match image_format {
ImageFormat::OpenAi => json!({
"type": "image_url",
"image_url": {
"url": format!("data:{};base64,{}", image.mime_type, image.data)
}
}),
ImageFormat::Anthropic => json!({
"type": "image",
"source": {
"type": "base64",
"media_type": image.mime_type,
"data": image.data,
}
}),
}
}
/// Handle response from OpenAI compatible endpoints
/// Error codes: https://platform.openai.com/docs/guides/error-codes
/// Context window exceeded: https://community.openai.com/t/help-needed-tackling-context-length-limits-in-openai-models/617543
pub async fn handle_response_openai_compat(response: Response) -> Result<Value, ProviderError> {
let status = response.status();
// Try to parse the response body as JSON (if applicable)
let payload = match response.json::<Value>().await {
Ok(json) => json,
Err(e) => return Err(ProviderError::RequestFailed(e.to_string())),
};
match status {
StatusCode::OK => Ok(payload),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
Err(ProviderError::Authentication(format!(
"Authentication failed. Please ensure your API keys are valid and have the required permissions. \
Status: {}. Response: {:?}",
status, payload
)))
}
StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND => {
tracing::debug!(
"{}",
format!(
"Provider request failed with status: {}. Payload: {:?}",
status, payload
)
);
if let Ok(err_resp) = from_value::<OpenAIErrorResponse>(payload) {
let err = err_resp.error;
if err.is_context_length_exceeded() {
return Err(ProviderError::ContextLengthExceeded(
err.message.unwrap_or("Unknown error".to_string()),
));
}
return Err(ProviderError::RequestFailed(format!(
"{} (status {})",
err,
status.as_u16()
)));
}
Err(ProviderError::RequestFailed(format!(
"Unknown error (status {})",
status
)))
}
StatusCode::TOO_MANY_REQUESTS => {
Err(ProviderError::RateLimitExceeded(format!("{:?}", payload)))
}
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => {
Err(ProviderError::ServerError(format!("{:?}", payload)))
}
_ => {
tracing::debug!(
"{}",
format!(
"Provider request failed with status: {}. Payload: {:?}",
status, payload
)
);
Err(ProviderError::RequestFailed(format!(
"Request failed with status: {}",
status
)))
}
}
}
/// Get a secret from environment variables. The secret is expected to be in JSON format.
pub fn get_env(key: &str) -> Result<String> {
// check environment variables (convert to uppercase)
let env_key = key.to_uppercase();
if let Ok(val) = env::var(&env_key) {
let value: Value = serde_json::from_str(&val).unwrap_or(Value::String(val));
Ok(serde_json::from_value(value)?)
} else {
Err(anyhow::anyhow!(
"Environment variable {} not found",
env_key
))
}
}
pub fn sanitize_function_name(name: &str) -> String {
let re = Regex::new(r"[^a-zA-Z0-9_-]").unwrap();
re.replace_all(name, "_").to_string()
}
pub fn is_valid_function_name(name: &str) -> bool {
let re = Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
re.is_match(name)
}
/// Extract the model name from a JSON object. Common with most providers to have this top level attribute.
pub fn get_model(data: &Value) -> String {
if let Some(model) = data.get("model") {
if let Some(model_str) = model.as_str() {
model_str.to_string()
} else {
"Unknown".to_string()
}
} else {
"Unknown".to_string()
}
}
/// Check if a file is actually an image by examining its magic bytes
fn is_image_file(path: &Path) -> bool {
if let Ok(mut file) = std::fs::File::open(path) {
let mut buffer = [0u8; 8]; // Large enough for most image magic numbers
if file.read(&mut buffer).is_ok() {
// Check magic numbers for common image formats
return match &buffer[0..4] {
// PNG: 89 50 4E 47
[0x89, 0x50, 0x4E, 0x47] => true,
// JPEG: FF D8 FF
[0xFF, 0xD8, 0xFF, _] => true,
_ => false,
};
}
}
false
}
/// Detect if a string contains a path to an image file
pub fn detect_image_path(text: &str) -> Option<&str> {
// Basic image file extension check
let extensions = [".png", ".jpg", ".jpeg"];
// Find any word that ends with an image extension
for word in text.split_whitespace() {
if extensions
.iter()
.any(|ext| word.to_lowercase().ends_with(ext))
{
let path = Path::new(word);
// Check if it's an absolute path and file exists
if path.is_absolute() && path.is_file() {
// Verify it's actually an image file
if is_image_file(path) {
return Some(word);
}
}
}
}
None
}
/// Convert a local image file to base64 encoded ImageContent
pub fn load_image_file(path: &str) -> Result<ImageContent, ProviderError> {
let path = Path::new(path);
// Verify it's an image before proceeding
if !is_image_file(path) {
return Err(ProviderError::RequestFailed(
"File is not a valid image".to_string(),
));
}
// Read the file
let bytes = std::fs::read(path)
.map_err(|e| ProviderError::RequestFailed(format!("Failed to read image file: {}", e)))?;
// Detect mime type from extension
let mime_type = match path.extension().and_then(|e| e.to_str()) {
Some(ext) => match ext.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
_ => {
return Err(ProviderError::RequestFailed(
"Unsupported image format".to_string(),
));
}
},
None => {
return Err(ProviderError::RequestFailed(
"Unknown image format".to_string(),
));
}
};
// Convert to base64
let data = base64::prelude::BASE64_STANDARD.encode(&bytes);
Ok(ImageContent {
mime_type: mime_type.to_string(),
data,
})
}
pub fn emit_debug_trace(
model_config: &ModelConfig,
payload: &Value,
response: &Value,
usage: &Usage,
) {
tracing::debug!(
model_config = %serde_json::to_string_pretty(model_config).unwrap_or_default(),
input = %serde_json::to_string_pretty(payload).unwrap_or_default(),
output = %serde_json::to_string_pretty(response).unwrap_or_default(),
input_tokens = ?usage.input_tokens.unwrap_or_default(),
output_tokens = ?usage.output_tokens.unwrap_or_default(),
total_tokens = ?usage.total_tokens.unwrap_or_default(),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_image_path() {
// Create a temporary PNG file with valid PNG magic numbers
let temp_dir = tempfile::tempdir().unwrap();
let png_path = temp_dir.path().join("test.png");
let png_data = [
0x89, 0x50, 0x4E, 0x47, // PNG magic number
0x0D, 0x0A, 0x1A, 0x0A, // PNG header
0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data
];
std::fs::write(&png_path, &png_data).unwrap();
let png_path_str = png_path.to_str().unwrap();
// Create a fake PNG (wrong magic numbers)
let fake_png_path = temp_dir.path().join("fake.png");
std::fs::write(&fake_png_path, b"not a real png").unwrap();
// Test with valid PNG file using absolute path
let text = format!("Here is an image {}", png_path_str);
assert_eq!(detect_image_path(&text), Some(png_path_str));
// Test with non-image file that has .png extension
let text = format!("Here is a fake image {}", fake_png_path.to_str().unwrap());
assert_eq!(detect_image_path(&text), None);
// Test with non-existent file
let text = "Here is a fake.png that doesn't exist";
assert_eq!(detect_image_path(text), None);
// Test with non-image file
let text = "Here is a file.txt";
assert_eq!(detect_image_path(text), None);
// Test with relative path (should not match)
let text = "Here is a relative/path/image.png";
assert_eq!(detect_image_path(text), None);
}
#[test]
fn test_load_image_file() {
// Create a temporary PNG file with valid PNG magic numbers
let temp_dir = tempfile::tempdir().unwrap();
let png_path = temp_dir.path().join("test.png");
let png_data = [
0x89, 0x50, 0x4E, 0x47, // PNG magic number
0x0D, 0x0A, 0x1A, 0x0A, // PNG header
0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data
];
std::fs::write(&png_path, &png_data).unwrap();
let png_path_str = png_path.to_str().unwrap();
// Create a fake PNG (wrong magic numbers)
let fake_png_path = temp_dir.path().join("fake.png");
std::fs::write(&fake_png_path, b"not a real png").unwrap();
let fake_png_path_str = fake_png_path.to_str().unwrap();
// Test loading valid PNG file
let result = load_image_file(png_path_str);
assert!(result.is_ok());
let image = result.unwrap();
assert_eq!(image.mime_type, "image/png");
// Test loading fake PNG file
let result = load_image_file(fake_png_path_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not a valid image"));
// Test non-existent file
let result = load_image_file("nonexistent.png");
assert!(result.is_err());
}
#[test]
fn test_sanitize_function_name() {
assert_eq!(sanitize_function_name("hello-world"), "hello-world");
assert_eq!(sanitize_function_name("hello world"), "hello_world");
assert_eq!(sanitize_function_name("hello@world"), "hello_world");
}
#[test]
fn test_is_valid_function_name() {
assert!(is_valid_function_name("hello-world"));
assert!(is_valid_function_name("hello_world"));
assert!(!is_valid_function_name("hello world"));
assert!(!is_valid_function_name("hello@world"));
}
}
-70
View File
@@ -1,70 +0,0 @@
use goose::message::Message;
use goose::providers::base::ProviderUsage;
use mcp_core::tool::Tool;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionResponse {
message: Message,
usage: ProviderUsage,
runtime_metrics: RuntimeMetrics,
}
impl CompletionResponse {
pub fn new(message: Message, usage: ProviderUsage, runtime_metrics: RuntimeMetrics) -> Self {
Self {
message,
usage,
runtime_metrics,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeMetrics {
pub total_time_ms: u128,
pub total_time_ms_provider: u128,
pub tokens_per_second: Option<f64>,
}
impl RuntimeMetrics {
pub fn new(
total_time_ms: u128,
total_time_ms_provider: u128,
tokens_per_second: Option<f64>,
) -> Self {
Self {
total_time_ms,
total_time_ms_provider,
tokens_per_second,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Extension {
name: String,
instructions: Option<String>,
tools: Vec<Tool>,
}
impl Extension {
pub fn new(name: String, instructions: Option<String>, tools: Vec<Tool>) -> Self {
Self {
name,
instructions,
tools,
}
}
pub fn get_prefixed_tools(&self) -> Vec<Tool> {
self.tools
.iter()
.map(|tool| {
let mut prefixed_tool = tool.clone();
prefixed_tool.name = format!("{}__{}", self.name, tool.name);
prefixed_tool
})
.collect()
}
}
+134
View File
@@ -0,0 +1,134 @@
// This file defines types for completion interfaces, including the request and response structures.
// Many of these are adapted based on the Goose Service API:
// https://docs.google.com/document/d/1r5vjSK3nBQU1cIRf0WKysDigqMlzzrzl_bxEE4msOiw/edit?tab=t.0
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{message::Message, providers::Usage};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionResponse {
pub message: Message,
pub model: String,
pub usage: Usage,
pub runtime_metrics: RuntimeMetrics,
}
impl CompletionResponse {
pub fn new(
message: Message,
model: String,
usage: Usage,
runtime_metrics: RuntimeMetrics,
) -> Self {
Self {
message,
model,
usage,
runtime_metrics,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeMetrics {
pub total_time_ms: u128,
pub total_time_ms_provider: u128,
pub tokens_per_second: Option<f64>,
}
impl RuntimeMetrics {
pub fn new(
total_time_ms: u128,
total_time_ms_provider: u128,
tokens_per_second: Option<f64>,
) -> Self {
Self {
total_time_ms,
total_time_ms_provider,
tokens_per_second,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum ToolApprovalMode {
Auto,
Manual,
Smart,
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolConfig {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
pub approval_mode: ToolApprovalMode,
}
impl ToolConfig {
pub fn new(
name: &str,
description: &str,
input_schema: serde_json::Value,
approval_mode: ToolApprovalMode,
) -> Self {
Self {
name: name.to_string(),
description: description.to_string(),
input_schema,
approval_mode,
}
}
/// Convert the tool config to a core tool
pub fn to_core_tool(&self, name: Option<&str>) -> super::core::Tool {
let tool_name = name.unwrap_or(&self.name);
super::core::Tool::new(
tool_name,
self.description.clone(),
self.input_schema.clone(),
)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ExtensionConfig {
name: String,
instructions: Option<String>,
tools: Vec<ToolConfig>,
}
impl ExtensionConfig {
pub fn new(name: String, instructions: Option<String>, tools: Vec<ToolConfig>) -> Self {
Self {
name,
instructions,
tools,
}
}
/// Convert the tools to core tools with the extension name as a prefix
pub fn get_prefixed_tools(&self) -> Vec<super::core::Tool> {
self.tools
.iter()
.map(|tool| {
let name = format!("{}__{}", self.name, tool.name);
tool.to_core_tool(Some(&name))
})
.collect()
}
/// Get a map of prefixed tool names to their approval modes
pub fn get_prefixed_tool_configs(&self) -> HashMap<String, ToolConfig> {
self.tools
.iter()
.map(|tool| {
let name = format!("{}__{}", self.name, tool.name);
(name, tool.clone())
})
.collect()
}
}
+131
View File
@@ -0,0 +1,131 @@
// This file defines core types that require serialization to
// construct payloads for LLM model providers and work with MCPs.
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Content {
Text(TextContent),
Image(ImageContent),
}
impl Content {
pub fn text<S: Into<String>>(text: S) -> Self {
Content::Text(TextContent { text: text.into() })
}
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
Content::Image(ImageContent {
data: data.into(),
mime_type: mime_type.into(),
})
}
/// Get the text content if this is a TextContent variant
pub fn as_text(&self) -> Option<&str> {
match self {
Content::Text(text) => Some(&text.text),
_ => None,
}
}
/// Get the image content if this is an ImageContent variant
pub fn as_image(&self) -> Option<(&str, &str)> {
match self {
Content::Image(image) => Some((&image.data, &image.mime_type)),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextContent {
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageContent {
pub data: String,
pub mime_type: String,
}
/// A tool that can be used by a model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
/// The name of the tool
pub name: String,
/// A description of what the tool does
pub description: String,
/// A JSON Schema object defining the expected parameters for the tool
pub input_schema: serde_json::Value,
}
impl Tool {
/// Create a new tool with the given name and description
pub fn new<N, D>(name: N, description: D, input_schema: serde_json::Value) -> Self
where
N: Into<String>,
D: Into<String>,
{
Tool {
name: name.into(),
description: description.into(),
input_schema,
}
}
}
/// A tool call request that an extension can execute
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCall {
/// The name of the tool to execute
pub name: String,
/// The parameters for the execution
pub arguments: serde_json::Value,
/// Whether the tool call needs approval before execution. Default is false.
pub needs_approval: bool,
}
impl ToolCall {
/// Create a new ToolUse with the given name and parameters
pub fn new<S: Into<String>>(name: S, arguments: serde_json::Value) -> Self {
Self {
name: name.into(),
arguments,
needs_approval: false,
}
}
/// Set needs_approval field
pub fn set_needs_approval(&mut self, flag: bool) {
self.needs_approval = flag;
}
}
#[non_exhaustive]
#[derive(Error, Debug, Clone, Deserialize, Serialize, PartialEq)]
pub enum ToolError {
#[error("Invalid parameters: {0}")]
InvalidParameters(String),
#[error("Execution failed: {0}")]
ExecutionError(String),
#[error("Schema error: {0}")]
SchemaError(String),
#[error("Tool not found: {0}")]
NotFound(String),
}
pub type ToolResult<T> = std::result::Result<T, ToolError>;
+2
View File
@@ -0,0 +1,2 @@
pub mod completion;
pub mod core;