From 8c9a89efded206bcb459cc081b2e0662f49abf70 Mon Sep 17 00:00:00 2001 From: Tyler White Date: Sun, 1 Jun 2025 23:37:00 -0400 Subject: [PATCH] feat: add Snowflake provider (#2488) Co-authored-by: Michael Neale --- crates/goose/src/providers/factory.rs | 3 + crates/goose/src/providers/formats/mod.rs | 1 + .../goose/src/providers/formats/snowflake.rs | 716 ++++++++++++++++++ crates/goose/src/providers/mod.rs | 1 + crates/goose/src/providers/snowflake.rs | 434 +++++++++++ crates/goose/tests/providers.rs | 13 +- .../settings/models/hardcoded_stuff.tsx | 8 + .../providers/ProviderRegistry.tsx | 18 + .../modal/subcomponents/ProviderLogo.tsx | 2 + .../modal/subcomponents/icons/snowflake.png | Bin 0 -> 2475 bytes .../subcomponents/icons/snowflake@2x.png | Bin 0 -> 5811 bytes .../subcomponents/icons/snowflake@3x.png | Bin 0 -> 9281 bytes 12 files changed, 1195 insertions(+), 1 deletion(-) create mode 100644 crates/goose/src/providers/formats/snowflake.rs create mode 100644 crates/goose/src/providers/snowflake.rs create mode 100644 ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake.png create mode 100644 ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake@2x.png create mode 100644 ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake@3x.png diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index da65c9ef91..42d7e69b9d 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -13,6 +13,7 @@ use super::{ ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider, + snowflake::SnowflakeProvider, venice::VeniceProvider, }; use crate::model::ModelConfig; @@ -32,6 +33,7 @@ pub fn providers() -> Vec { OpenAiProvider::metadata(), OpenRouterProvider::metadata(), VeniceProvider::metadata(), + SnowflakeProvider::metadata(), ] } @@ -49,6 +51,7 @@ pub fn create(name: &str, model: ModelConfig) -> Result> { "gcp_vertex_ai" => Ok(Arc::new(GcpVertexAIProvider::from_env(model)?)), "google" => Ok(Arc::new(GoogleProvider::from_env(model)?)), "venice" => Ok(Arc::new(VeniceProvider::from_env(model)?)), + "snowflake" => Ok(Arc::new(SnowflakeProvider::from_env(model)?)), "github_copilot" => Ok(Arc::new(GithubCopilotProvider::from_env(model)?)), _ => Err(anyhow::anyhow!("Unknown provider: {}", name)), } diff --git a/crates/goose/src/providers/formats/mod.rs b/crates/goose/src/providers/formats/mod.rs index fc767e2724..6f3df3d0bf 100644 --- a/crates/goose/src/providers/formats/mod.rs +++ b/crates/goose/src/providers/formats/mod.rs @@ -4,3 +4,4 @@ pub mod databricks; pub mod gcpvertexai; pub mod google; pub mod openai; +pub mod snowflake; diff --git a/crates/goose/src/providers/formats/snowflake.rs b/crates/goose/src/providers/formats/snowflake.rs new file mode 100644 index 0000000000..c7cfe5924b --- /dev/null +++ b/crates/goose/src/providers/formats/snowflake.rs @@ -0,0 +1,716 @@ +use crate::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::providers::base::Usage; +use crate::providers::errors::ProviderError; +use anyhow::{anyhow, Result}; +use mcp_core::content::Content; +use mcp_core::role::Role; +use mcp_core::tool::{Tool, ToolCall}; +use serde_json::{json, Value}; +use std::collections::HashSet; + +/// Convert internal Message format to Snowflake's API message specification +pub fn format_messages(messages: &[Message]) -> Vec { + let mut snowflake_messages = Vec::new(); + + // Convert messages to Snowflake format + for message in messages { + let role = match message.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + + let mut text_content = String::new(); + + for msg_content in &message.content { + match msg_content { + MessageContent::Text(text) => { + if !text_content.is_empty() { + text_content.push('\n'); + } + text_content.push_str(&text.text); + } + MessageContent::ToolRequest(_tool_request) => { + // Skip tool requests in message formatting - tools are handled separately + // through the tools parameter in the API request + continue; + } + MessageContent::ToolResponse(tool_response) => { + if let Ok(result) = &tool_response.tool_result { + let text = result + .iter() + .filter_map(|c| match c { + Content::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join("\n"); + + if !text_content.is_empty() { + text_content.push('\n'); + } + if !text.is_empty() { + text_content.push_str(&format!("Tool result: {}", text)); + } + } + } + MessageContent::ToolConfirmationRequest(_) => { + // Skip tool confirmation requests + } + MessageContent::ContextLengthExceeded(_) => { + // Skip + } + MessageContent::SummarizationRequested(_) => { + // Skip + } + MessageContent::Thinking(_thinking) => { + // Skip thinking for now + } + MessageContent::RedactedThinking(_redacted) => { + // Skip redacted thinking for now + } + MessageContent::Image(_) => continue, // Snowflake doesn't support image content yet + MessageContent::FrontendToolRequest(_tool_request) => { + // Skip frontend tool requests + } + } + } + + // Add message if it has text content + if !text_content.is_empty() { + snowflake_messages.push(json!({ + "role": role, + "content": text_content + })); + } + } + + // Only add default message if we truly have no messages at all + // This should be rare and only for edge cases + if snowflake_messages.is_empty() { + snowflake_messages.push(json!({ + "role": "user", + "content": "Continue the conversation" + })); + } + + snowflake_messages +} + +/// Convert internal Tool format to Snowflake's API tool specification +pub fn format_tools(tools: &[Tool]) -> Vec { + let mut unique_tools = HashSet::new(); + let mut tool_specs = Vec::new(); + + for tool in tools.iter() { + if unique_tools.insert(tool.name.clone()) { + let tool_spec = json!({ + "type": "generic", + "name": tool.name, + "description": tool.description, + "input_schema": tool.input_schema + }); + + tool_specs.push(json!({"tool_spec": tool_spec})); + } + } + + tool_specs +} + +/// Convert system message to Snowflake's API system specification +pub fn format_system(system: &str) -> Value { + json!({ + "role": "system", + "content": system, + }) +} + +/// Convert Snowflake's streaming API response to internal Message format +pub fn parse_streaming_response(sse_data: &str) -> Result { + let mut message = Message::assistant(); + let mut accumulated_text = String::new(); + let mut tool_use_id: Option = None; + let mut tool_name: Option = None; + let mut tool_input = String::new(); + + // Parse each SSE event + for line in sse_data.lines() { + if !line.starts_with("data: ") { + continue; + } + + let json_str = &line[6..]; // Remove "data: " prefix + if json_str.trim().is_empty() || json_str.trim() == "[DONE]" { + continue; + } + + let event: Value = match serde_json::from_str(json_str) { + Ok(v) => v, + Err(_) => { + continue; + } + }; + + if let Some(choices) = event.get("choices").and_then(|c| c.as_array()) { + if let Some(choice) = choices.first() { + if let Some(delta) = choice.get("delta") { + match delta.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { + accumulated_text.push_str(content); + } + } + Some("tool_use") => { + if let Some(id) = delta.get("tool_use_id").and_then(|i| i.as_str()) { + tool_use_id = Some(id.to_string()); + } + if let Some(name) = delta.get("name").and_then(|n| n.as_str()) { + tool_name = Some(name.to_string()); + } + if let Some(input) = delta.get("input").and_then(|i| i.as_str()) { + tool_input.push_str(input); + } + } + _ => {} + } + } + } + } + } + + // Add accumulated text if any + if !accumulated_text.is_empty() { + message = message.with_text(accumulated_text); + } + + // Add tool use if complete + if let (Some(id), Some(name)) = (&tool_use_id, &tool_name) { + if !tool_input.is_empty() { + let input_value = serde_json::from_str::(&tool_input) + .unwrap_or_else(|_| Value::String(tool_input.clone())); + let tool_call = ToolCall::new(name, input_value); + message = message.with_tool_request(id, Ok(tool_call)); + } else if tool_name.is_some() { + // Tool with no input - use empty object + let tool_call = ToolCall::new(name, Value::Object(serde_json::Map::new())); + message = message.with_tool_request(id, Ok(tool_call)); + } + } + + Ok(message) +} + +/// Convert Snowflake's API response to internal Message format +pub fn response_to_message(response: Value) -> Result { + let mut message = Message::assistant(); + + let content_list = response.get("content_list").and_then(|cl| cl.as_array()); + + // Handle case where content_list is missing or empty + let content_list = match content_list { + Some(list) if !list.is_empty() => list, + _ => { + // If no content_list or empty, check if there's a direct content field + if let Some(direct_content) = response.get("content").and_then(|c| c.as_str()) { + if !direct_content.is_empty() { + message = message.with_text(direct_content.to_string()); + } + return Ok(message); + } else { + // Return empty assistant message for empty responses + return Ok(message); + } + } + }; + + // Process all content items in the list + for content in content_list { + match content.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = content.get("text").and_then(|t| t.as_str()) { + if !text.is_empty() { + message = message.with_text(text.to_string()); + } + } + } + Some("tool_use") => { + let id = content + .get("tool_use_id") + .and_then(|i| i.as_str()) + .ok_or_else(|| anyhow!("Missing tool_use id"))?; + let name = content + .get("name") + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow!("Missing tool_use name"))?; + + let input = content + .get("input") + .ok_or_else(|| anyhow!("Missing tool input"))? + .clone(); + + let tool_call = ToolCall::new(name, input); + message = message.with_tool_request(id, Ok(tool_call)); + } + Some("thinking") => { + let thinking = content + .get("thinking") + .and_then(|t| t.as_str()) + .ok_or_else(|| anyhow!("Missing thinking content"))?; + let signature = content + .get("signature") + .and_then(|s| s.as_str()) + .ok_or_else(|| anyhow!("Missing thinking signature"))?; + message = message.with_thinking(thinking, signature); + } + Some("redacted_thinking") => { + let data = content + .get("data") + .and_then(|d| d.as_str()) + .ok_or_else(|| anyhow!("Missing redacted_thinking data"))?; + message = message.with_redacted_thinking(data); + } + _ => { + // Ignore unrecognized content types + } + } + } + + Ok(message) +} + +/// Extract usage information from Snowflake's API response +pub fn get_usage(data: &Value) -> Result { + // Extract usage data if available + if let Some(usage) = data.get("usage") { + let input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + + let output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + + let total_tokens = match (input_tokens, output_tokens) { + (Some(input), Some(output)) => Some(input + output), + _ => None, + }; + + Ok(Usage::new(input_tokens, output_tokens, total_tokens)) + } else { + tracing::debug!( + "Failed to get usage data: {}", + ProviderError::UsageError("No usage data found in response".to_string()) + ); + // If no usage data, return None for all values + Ok(Usage::new(None, None, None)) + } +} + +/// Create a complete request payload for Snowflake's API +pub fn create_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], +) -> Result { + let mut snowflake_messages = format_messages(messages); + let system_spec = format_system(system); + + // Add system message to the beginning of the messages + snowflake_messages.insert(0, system_spec); + + // Check if we have any messages to send + if snowflake_messages.is_empty() { + return Err(anyhow!("No valid messages to send to Snowflake API")); + } + + // Detect description generation requests and exclude tools to prevent interference + // with normal tool execution flow + let is_description_request = + system.contains("Reply with only a description in four words or less"); + + let tool_specs = if is_description_request { + // For description generation, don't include any tools to avoid confusion + format_tools(&[]) + } else { + format_tools(tools) + }; + + let max_tokens = model_config.max_tokens.unwrap_or(4096); + let mut payload = json!({ + "model": model_config.model_name, + "messages": snowflake_messages, + "max_tokens": max_tokens, + }); + + // Add tools if present and not a description request + if !tool_specs.is_empty() { + if let Some(obj) = payload.as_object_mut() { + obj.insert("tools".to_string(), json!(tool_specs)); + } else { + return Err(anyhow!( + "Failed to create request payload: payload is not a JSON object" + )); + } + } + + Ok(payload) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_parse_text_response() -> Result<()> { + let response = json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content_list": [{ + "type": "text", + "text": "Hello! How can I assist you today?" + }], + "model": "claude-3-5-sonnet", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 15 + } + }); + + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + + if let MessageContent::Text(text) = &message.content[0] { + assert_eq!(text.text, "Hello! How can I assist you today?"); + } else { + panic!("Expected Text content"); + } + + assert_eq!(usage.input_tokens, Some(12)); + assert_eq!(usage.output_tokens, Some(15)); + assert_eq!(usage.total_tokens, Some(27)); // 12 + 15 + + Ok(()) + } + + #[test] + fn test_parse_tool_response() -> Result<()> { + let response = json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content_list": [{ + "type": "tool_use", + "tool_use_id": "tool_1", + "name": "calculator", + "input": {"expression": "2 + 2"} + }], + "model": "claude-3-5-sonnet", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 15, + "output_tokens": 20 + } + }); + + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + + if let MessageContent::ToolRequest(tool_request) = &message.content[0] { + let tool_call = tool_request.tool_call.as_ref().unwrap(); + assert_eq!(tool_call.name, "calculator"); + assert_eq!(tool_call.arguments, json!({"expression": "2 + 2"})); + } else { + panic!("Expected ToolRequest content"); + } + + assert_eq!(usage.input_tokens, Some(15)); + assert_eq!(usage.output_tokens, Some(20)); + assert_eq!(usage.total_tokens, Some(35)); // 15 + 20 + + Ok(()) + } + + #[test] + fn test_message_to_snowflake_spec() { + let messages = vec![ + Message::user().with_text("Hello"), + Message::assistant().with_text("Hi there"), + Message::user().with_text("How are you?"), + ]; + + let spec = format_messages(&messages); + + assert_eq!(spec.len(), 3); + assert_eq!(spec[0]["role"], "user"); + assert_eq!(spec[0]["content"], "Hello"); + assert_eq!(spec[1]["role"], "assistant"); + assert_eq!(spec[1]["content"], "Hi there"); + assert_eq!(spec[2]["role"], "user"); + assert_eq!(spec[2]["content"], "How are you?"); + } + + #[test] + fn test_tools_to_snowflake_spec() { + let tools = vec![ + Tool::new( + "calculator", + "Calculate mathematical expressions", + json!({ + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "The mathematical expression to evaluate" + } + } + }), + None, + ), + Tool::new( + "weather", + "Get weather information", + json!({ + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The location to get weather for" + } + } + }), + None, + ), + ]; + + let spec = format_tools(&tools); + + assert_eq!(spec.len(), 2); + assert_eq!(spec[0]["tool_spec"]["name"], "calculator"); + assert_eq!( + spec[0]["tool_spec"]["description"], + "Calculate mathematical expressions" + ); + assert_eq!(spec[1]["tool_spec"]["name"], "weather"); + assert_eq!( + spec[1]["tool_spec"]["description"], + "Get weather information" + ); + } + + #[test] + fn test_system_to_snowflake_spec() { + let system = "You are a helpful assistant."; + let spec = format_system(system); + + assert_eq!(spec["role"], "system"); + assert_eq!(spec["content"], system); + } + + #[test] + fn test_parse_streaming_response() -> Result<()> { + let sse_data = r#"data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","choices":[{"delta":{"type":"text","content":"I","content_list":[{"type":"text","text":"I"}],"text":"I"}}],"usage":{}} + +data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","choices":[{"delta":{"type":"text","content":"'ll help you check Nvidia's current","content_list":[{"type":"text","text":"'ll help you check Nvidia's current"}],"text":"'ll help you check Nvidia's current"}}],"usage":{}} + +data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","choices":[{"delta":{"type":"tool_use","tool_use_id":"tooluse_FB_nOElDTAOKa-YnVWI5Uw","name":"get_stock_price","content_list":[{"tool_use_id":"tooluse_FB_nOElDTAOKa-YnVWI5Uw","name":"get_stock_price"}],"text":""}}],"usage":{}} + +data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","choices":[{"delta":{"type":"tool_use","input":"{\"symbol\":\"NVDA\"}","content_list":[{"input":"{\"symbol\":\"NVDA\"}"}],"text":""}}],"usage":{"prompt_tokens":397,"completion_tokens":65,"total_tokens":462}} +"#; + + let message = parse_streaming_response(sse_data)?; + + // Should have both text and tool request + assert_eq!(message.content.len(), 2); + + if let MessageContent::Text(text) = &message.content[0] { + assert!(text.text.contains("I'll help you check Nvidia's current")); + } else { + panic!("Expected Text content first"); + } + + if let MessageContent::ToolRequest(tool_request) = &message.content[1] { + let tool_call = tool_request.tool_call.as_ref().unwrap(); + assert_eq!(tool_call.name, "get_stock_price"); + assert_eq!(tool_call.arguments, json!({"symbol": "NVDA"})); + assert_eq!(tool_request.id, "tooluse_FB_nOElDTAOKa-YnVWI5Uw"); + } else { + panic!("Expected ToolRequest content second"); + } + + Ok(()) + } + + #[test] + fn test_create_request_format() -> Result<()> { + use crate::model::ModelConfig; + + let model_config = ModelConfig::new("claude-3-5-sonnet".to_string()); + + let system = "You are a helpful assistant that can use tools to get information."; + let messages = vec![Message::user().with_text("What is the stock price of Nvidia?")]; + + let tools = vec![Tool::new( + "get_stock_price", + "Get stock price information", + json!({ + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "The symbol for the stock ticker, e.g. Snowflake = SNOW" + } + }, + "required": ["symbol"] + }), + None, + )]; + + let request = create_request(&model_config, system, &messages, &tools)?; + + // Check basic structure + assert_eq!(request["model"], "claude-3-5-sonnet"); + + let messages_array = request["messages"].as_array().unwrap(); + assert_eq!(messages_array.len(), 2); // system + user message + + // First message should be system with simple content + assert_eq!(messages_array[0]["role"], "system"); + assert_eq!( + messages_array[0]["content"], + "You are a helpful assistant that can use tools to get information." + ); + + // Second message should be user with simple content + assert_eq!(messages_array[1]["role"], "user"); + assert_eq!( + messages_array[1]["content"], + "What is the stock price of Nvidia?" + ); + + // Tools should have tool_spec wrapper + let tools_array = request["tools"].as_array().unwrap(); + assert_eq!(tools_array[0]["tool_spec"]["name"], "get_stock_price"); + + Ok(()) + } + + #[test] + fn test_parse_mixed_text_and_tool_response() -> Result<()> { + let response = json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": "I'll help you with that calculation.", + "content_list": [ + { + "type": "text", + "text": "I'll help you with that calculation." + }, + { + "type": "tool_use", + "tool_use_id": "tool_1", + "name": "calculator", + "input": {"expression": "2 + 2"} + } + ], + "model": "claude-3-5-sonnet", + "usage": { + "input_tokens": 10, + "output_tokens": 15 + } + }); + + let message = response_to_message(response.clone())?; + + // Should have both text and tool request content + assert_eq!(message.content.len(), 2); + + if let MessageContent::Text(text) = &message.content[0] { + assert_eq!(text.text, "I'll help you with that calculation."); + } else { + panic!("Expected Text content first"); + } + + if let MessageContent::ToolRequest(tool_request) = &message.content[1] { + let tool_call = tool_request.tool_call.as_ref().unwrap(); + assert_eq!(tool_call.name, "calculator"); + assert_eq!(tool_request.id, "tool_1"); + } else { + panic!("Expected ToolRequest content second"); + } + + Ok(()) + } + + #[test] + fn test_empty_tools_array() { + let tools: Vec = vec![]; + let spec = format_tools(&tools); + assert_eq!(spec.len(), 0); + } + + #[test] + fn test_create_request_excludes_tools_for_description() -> Result<()> { + use crate::model::ModelConfig; + + let model_config = ModelConfig::new("claude-3-5-sonnet".to_string()); + let system = "Reply with only a description in four words or less"; + let messages = vec![Message::user().with_text("Test message")]; + let tools = vec![Tool::new( + "test_tool", + "Test tool", + json!({"type": "object", "properties": {}}), + None, + )]; + + let request = create_request(&model_config, system, &messages, &tools)?; + + // Should not include tools for description requests + assert!(request.get("tools").is_none()); + + Ok(()) + } + + #[test] + fn test_message_formatting_skips_tool_requests() { + use mcp_core::tool::ToolCall; + + // Create a conversation with text, tool requests, and tool responses + let tool_call = ToolCall::new("calculator", json!({"expression": "2 + 2"})); + + let messages = vec![ + Message::user().with_text("Calculate 2 + 2"), + Message::assistant() + .with_text("I'll help you calculate that.") + .with_tool_request("tool_1", Ok(tool_call)), + Message::user().with_text("Thanks!"), + ]; + + let spec = format_messages(&messages); + + // Should only have 3 messages - the tool request should be skipped + assert_eq!(spec.len(), 3); + assert_eq!(spec[0]["role"], "user"); + assert_eq!(spec[0]["content"], "Calculate 2 + 2"); + assert_eq!(spec[1]["role"], "assistant"); + assert_eq!(spec[1]["content"], "I'll help you calculate that."); + assert_eq!(spec[2]["role"], "user"); + assert_eq!(spec[2]["content"], "Thanks!"); + + // Verify no tool request content is in the message history + for message in &spec { + let content = message["content"].as_str().unwrap(); + assert!(!content.contains("Using tool:")); + assert!(!content.contains("calculator")); + } + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index c91e43c626..2c1a4571b6 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -17,6 +17,7 @@ pub mod oauth; pub mod ollama; pub mod openai; pub mod openrouter; +pub mod snowflake; pub mod toolshim; pub mod utils; pub mod utils_universal_openai_stream; diff --git a/crates/goose/src/providers/snowflake.rs b/crates/goose/src/providers/snowflake.rs new file mode 100644 index 0000000000..dcd0ac444b --- /dev/null +++ b/crates/goose/src/providers/snowflake.rs @@ -0,0 +1,434 @@ +use anyhow::Result; +use async_trait::async_trait; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::time::Duration; + +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use super::formats::snowflake::{create_request, get_usage, response_to_message}; +use super::utils::{get_model, ImageFormat}; +use crate::config::ConfigError; +use crate::message::Message; +use crate::model::ModelConfig; +use mcp_core::tool::Tool; +use url::Url; + +pub const SNOWFLAKE_DEFAULT_MODEL: &str = "claude-3-7-sonnet"; +pub const SNOWFLAKE_KNOWN_MODELS: &[&str] = &["claude-3-7-sonnet", "claude-3-5-sonnet"]; + +pub const SNOWFLAKE_DOC_URL: &str = + "https://docs.snowflake.com/en/user-guide/snowflake-cortex/llm-functions#choosing-a-model"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SnowflakeAuth { + Token(String), +} + +impl SnowflakeAuth { + pub fn token(token: String) -> Self { + Self::Token(token) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct SnowflakeProvider { + #[serde(skip)] + client: Client, + host: String, + auth: SnowflakeAuth, + model: ModelConfig, + image_format: ImageFormat, +} + +impl Default for SnowflakeProvider { + fn default() -> Self { + let model = ModelConfig::new(SnowflakeProvider::metadata().default_model); + SnowflakeProvider::from_env(model).expect("Failed to initialize Snowflake provider") + } +} + +impl SnowflakeProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let mut host: Result = config.get_param("SNOWFLAKE_HOST"); + if host.is_err() { + host = config.get_secret("SNOWFLAKE_HOST") + } + if host.is_err() { + return Err(ConfigError::NotFound( + "Did not find SNOWFLAKE_HOST in either config file or keyring".to_string(), + ) + .into()); + } + + let mut host = host?; + + // Convert host to lowercase + host = host.to_lowercase(); + + // Ensure host ends with snowflakecomputing.com + if !host.ends_with("snowflakecomputing.com") { + host = format!("{}.snowflakecomputing.com", host); + } + + let mut token: Result = config.get_param("SNOWFLAKE_TOKEN"); + + if token.is_err() { + token = config.get_secret("SNOWFLAKE_TOKEN") + } + + if token.is_err() { + return Err(ConfigError::NotFound( + "Did not find SNOWFLAKE_TOKEN in either config file or keyring".to_string(), + ) + .into()); + } + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + // Use token-based authentication + let api_key = token?; + Ok(Self { + client, + host, + auth: SnowflakeAuth::token(api_key), + model, + image_format: ImageFormat::OpenAi, + }) + } + + async fn ensure_auth_header(&self) -> Result { + match &self.auth { + // https://docs.snowflake.com/en/developer-guide/snowflake-rest-api/authentication#using-a-programmatic-access-token-pat + SnowflakeAuth::Token(token) => Ok(format!("Bearer {}", token)), + } + } + + async fn post(&self, payload: Value) -> Result { + let base_url_str = + if !self.host.starts_with("https://") && !self.host.starts_with("http://") { + format!("https://{}", self.host) + } else { + self.host.clone() + }; + let base_url = Url::parse(&base_url_str) + .map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?; + let path = "api/v2/cortex/inference:complete"; + 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) + .header("User-Agent", "Goose") + .json(&payload) + .send() + .await?; + + let status = response.status(); + + let payload_text: String = response.text().await.ok().unwrap_or_default(); + + if status == StatusCode::OK { + if let Ok(payload) = serde_json::from_str::(&payload_text) { + if payload.get("code").is_some() { + let code = payload + .get("code") + .and_then(|c| c.as_str()) + .unwrap_or("Unknown code"); + let message = payload + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown message"); + return Err(ProviderError::RequestFailed(format!( + "{} - {}", + code, message + ))); + } + } + } + + let lines = payload_text.lines().collect::>(); + + let mut text = String::new(); + let mut tool_name = String::new(); + let mut tool_input = String::new(); + let mut tool_use_id = String::new(); + for line in lines.iter() { + if line.is_empty() { + continue; + } + + let json_str = match line.strip_prefix("data: ") { + Some(s) => s, + None => continue, + }; + + if let Ok(json_line) = serde_json::from_str::(json_str) { + let choices = match json_line.get("choices").and_then(|c| c.as_array()) { + Some(choices) => choices, + None => { + continue; + } + }; + + let choice = match choices.first() { + Some(choice) => choice, + None => { + continue; + } + }; + + let delta = match choice.get("delta") { + Some(delta) => delta, + None => { + continue; + } + }; + + // Track if we found text in content_list to avoid duplication + let mut found_text_in_content_list = false; + + // Handle content_list array first + if let Some(content_list) = delta.get("content_list").and_then(|cl| cl.as_array()) { + for content_item in content_list { + match content_item.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text_content) = + content_item.get("text").and_then(|t| t.as_str()) + { + text.push_str(text_content); + found_text_in_content_list = true; + } + } + Some("tool_use") => { + if let Some(tool_id) = + content_item.get("tool_use_id").and_then(|id| id.as_str()) + { + tool_use_id.push_str(tool_id); + } + if let Some(name) = + content_item.get("name").and_then(|n| n.as_str()) + { + tool_name.push_str(name); + } + if let Some(input) = + content_item.get("input").and_then(|i| i.as_str()) + { + tool_input.push_str(input); + } + } + _ => { + // Handle content items without explicit type but with tool information + if let Some(name) = + content_item.get("name").and_then(|n| n.as_str()) + { + tool_name.push_str(name); + } + if let Some(tool_id) = + content_item.get("tool_use_id").and_then(|id| id.as_str()) + { + tool_use_id.push_str(tool_id); + } + if let Some(input) = + content_item.get("input").and_then(|i| i.as_str()) + { + tool_input.push_str(input); + } + } + } + } + } + + // Handle direct content field (for text) only if we didn't find text in content_list + if !found_text_in_content_list { + if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { + text.push_str(content); + } + } + } + } + + // Build the appropriate response structure + let mut content_list = Vec::new(); + + // Add text content if available + if !text.is_empty() { + content_list.push(json!({ + "type": "text", + "text": text + })); + } + + // Add tool use content only if we have complete tool information + if !tool_use_id.is_empty() && !tool_name.is_empty() { + // Parse tool input as JSON if it's not empty + let parsed_input = if tool_input.is_empty() { + json!({}) + } else { + serde_json::from_str::(&tool_input) + .unwrap_or_else(|_| json!({"raw_input": tool_input})) + }; + + content_list.push(json!({ + "type": "tool_use", + "tool_use_id": tool_use_id, + "name": tool_name, + "input": parsed_input + })); + } + + // Ensure we always have at least some content + if content_list.is_empty() { + content_list.push(json!({ + "type": "text", + "text": "" + })); + } + + let answer_payload = json!({ + "role": "assistant", + "content": text, + "content_list": content_list + }); + + match status { + StatusCode::OK => Ok(answer_payload), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + // Extract a clean error message from the response if available + let error_msg = payload_text + .lines() + .find(|line| line.contains("\"message\"")) + .and_then(|line| { + let json_str = line.strip_prefix("data: ").unwrap_or(line); + serde_json::from_str::(json_str).ok() + }) + .and_then(|json| { + json.get("message") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "Invalid credentials".to_string()); + + Err(ProviderError::Authentication(format!( + "Authentication failed. Please check your SNOWFLAKE_TOKEN and SNOWFLAKE_HOST configuration. Error: {}", + error_msg + ))) + } + StatusCode::BAD_REQUEST => { + // Snowflake 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 = payload_text.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("Request exceeds maximum context length. Please reduce the number of messages or content size.".to_string())); + } + + // Try to parse a clean error message from the response + let error_msg = if let Ok(json) = serde_json::from_str::(&payload_text) { + json.get("message") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + json.get("external_model_message") + .and_then(|ext| ext.get("message")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "Bad request".to_string()) + } else { + "Bad request".to_string() + }; + + tracing::debug!( + "Provider request failed with status: {}. Response: {}", + status, + payload_text + ); + Err(ProviderError::RequestFailed(format!( + "Request failed: {}", + error_msg + ))) + } + StatusCode::TOO_MANY_REQUESTS => Err(ProviderError::RateLimitExceeded( + "Rate limit exceeded. Please try again later.".to_string(), + )), + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + Err(ProviderError::ServerError( + "Snowflake service is temporarily unavailable. Please try again later." + .to_string(), + )) + } + _ => { + tracing::debug!( + "Provider request failed with status: {}. Response: {}", + status, + payload_text + ); + Err(ProviderError::RequestFailed(format!( + "Request failed with status: {}", + status + ))) + } + } + } +} + +#[async_trait] +impl Provider for SnowflakeProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "snowflake", + "Snowflake", + "Access several models using Snowflake Cortex services.", + SNOWFLAKE_DEFAULT_MODEL, + SNOWFLAKE_KNOWN_MODELS.to_vec(), + SNOWFLAKE_DOC_URL, + vec![ + ConfigKey::new("SNOWFLAKE_HOST", true, false, None), + ConfigKey::new("SNOWFLAKE_TOKEN", true, true, None), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[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<(Message, ProviderUsage), ProviderError> { + let payload = create_request(&self.model, system, messages, tools)?; + + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + let model = get_model(&response); + super::utils::emit_debug_trace(&self.model, &payload, &response, &usage); + + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index e65aff6618..4d41251fc4 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -4,7 +4,7 @@ use goose::message::{Message, MessageContent}; use goose::providers::base::Provider; use goose::providers::errors::ProviderError; use goose::providers::{ - anthropic, azure, bedrock, databricks, google, groq, ollama, openai, openrouter, + anthropic, azure, bedrock, databricks, google, groq, ollama, openai, openrouter, snowflake, }; use mcp_core::content::Content; use mcp_core::tool::Tool; @@ -479,6 +479,17 @@ async fn test_google_provider() -> Result<()> { .await } +#[tokio::test] +async fn test_snowflake_provider() -> Result<()> { + test_provider( + "Snowflake", + &["SNOWFLAKE_HOST", "SNOWFLAKE_TOKEN"], + None, + snowflake::SnowflakeProvider::default, + ) + .await +} + // Print the final test report #[ctor::dtor] fn print_test_report() { diff --git a/ui/desktop/src/components/settings/models/hardcoded_stuff.tsx b/ui/desktop/src/components/settings/models/hardcoded_stuff.tsx index 20b464fbb8..1b885096b8 100644 --- a/ui/desktop/src/components/settings/models/hardcoded_stuff.tsx +++ b/ui/desktop/src/components/settings/models/hardcoded_stuff.tsx @@ -9,6 +9,7 @@ export const default_models = { azure_openai: 'gpt-4o', gcp_vertex_ai: 'gemini-2.0-flash-001', aws_bedrock: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + snowflake: 'claude-3-5-sonnet', venice: 'llama-3.3-70b', }; @@ -24,6 +25,7 @@ export const required_keys = { Ollama: ['OLLAMA_HOST'], Google: ['GOOGLE_API_KEY'], OpenRouter: ['OPENROUTER_API_KEY'], + Snowflake: ['SNOWFLAKE_HOST', 'SNOWFLAKE_TOKEN'], 'Azure OpenAI': [ 'AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_DEPLOYMENT_NAME', @@ -53,6 +55,7 @@ export const supported_providers = [ 'Azure OpenAI', 'GCP Vertex AI', 'AWS Bedrock', + 'Snowflake', ]; export const model_docs_link = [ @@ -68,6 +71,10 @@ export const model_docs_link = [ { name: 'Ollama', href: 'https://ollama.com/library' }, { name: 'GCP Vertex AI', href: 'https://cloud.google.com/vertex-ai' }, { name: 'AWS Bedrock', href: 'https://console.aws.amazon.com/bedrock/home#/model-catalog' }, + { + name: 'Snowflake', + href: 'https://docs.snowflake.com/user-guide/snowflake-cortex/llm-functions#availability', + }, ]; export const provider_aliases = [ @@ -81,5 +88,6 @@ export const provider_aliases = [ { provider: 'Azure OpenAI', alias: 'azure_openai' }, { provider: 'GCP Vertex AI', alias: 'gcp_vertex_ai' }, { provider: 'AWS Bedrock', alias: 'aws_bedrock' }, + { provider: 'Snowflake', alias: 'snowflake' }, { provider: 'Venice', alias: 'venice' }, ]; diff --git a/ui/desktop/src/components/settings_v2/providers/ProviderRegistry.tsx b/ui/desktop/src/components/settings_v2/providers/ProviderRegistry.tsx index 5f5b1fd8ae..1e55f5c2f8 100644 --- a/ui/desktop/src/components/settings_v2/providers/ProviderRegistry.tsx +++ b/ui/desktop/src/components/settings_v2/providers/ProviderRegistry.tsx @@ -216,4 +216,22 @@ export const PROVIDER_REGISTRY: ProviderRegistry[] = [ ], }, }, + { + name: 'Snowflake', + details: { + id: 'snowflake', + name: 'Snowflake', + description: 'Access Cortex models hosted on your Snowflake account', + parameters: [ + { + name: 'SNOWFLAKE_HOST', + is_secret: false, + }, + { + name: 'SNOWFLAKE_TOKEN', + is_secret: true, + }, + ], + }, + }, ]; diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderLogo.tsx b/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderLogo.tsx index 465546cc27..585b436c0d 100644 --- a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderLogo.tsx +++ b/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderLogo.tsx @@ -5,6 +5,7 @@ import GroqLogo from './icons/groq@3x.png'; import OllamaLogo from './icons/ollama@3x.png'; import DatabricksLogo from './icons/databricks@3x.png'; import OpenRouterLogo from './icons/openrouter@3x.png'; +import SnowflakeLogo from './icons/snowflake@3x.png'; import DefaultLogo from './icons/default@3x.png'; // Map provider names to their logos @@ -16,6 +17,7 @@ const providerLogos: Record = { ollama: OllamaLogo, databricks: DatabricksLogo, openrouter: OpenRouterLogo, + snowflake: SnowflakeLogo, default: DefaultLogo, }; diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake.png b/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake.png new file mode 100644 index 0000000000000000000000000000000000000000..07971ea060697acd0ad5d5dc3118d042c28007a8 GIT binary patch literal 2475 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 literal 0 HcmV?d00001 diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake@2x.png b/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5414984ed4b188525ff7ea53186740d7839e3920 GIT binary patch literal 5811 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 d`G~jFncb3Q(P$s4;a`Ig