fix: replace Venice custom provider with declarative config (#9234)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-05-15 10:38:11 -04:00
committed by GitHub
parent be0f69cf84
commit 001dcd7e9d
4 changed files with 26 additions and 569 deletions
@@ -0,0 +1,25 @@
{
"name": "venice",
"engine": "openai",
"display_name": "Venice.ai",
"description": "Venice.ai models (Llama, DeepSeek, Mistral) with function calling",
"api_key_env": "VENICE_API_KEY",
"base_url": "https://api.venice.ai/api/v1/chat/completions",
"models": [
{
"name": "llama-3.3-70b",
"context_limit": 131072
},
{
"name": "llama-3.2-3b",
"context_limit": 131072
},
{
"name": "mistral-31-24b",
"context_limit": 131072
}
],
"dynamic_models": true,
"supports_streaming": true,
"model_doc_link": "https://docs.venice.ai/"
}
-2
View File
@@ -35,7 +35,6 @@ use super::{
provider_registry::ProviderRegistry,
snowflake::SnowflakeProvider,
tetrate::TetrateProvider,
venice::VeniceProvider,
xai::XaiProvider,
};
use crate::config::ExtensionConfig;
@@ -84,7 +83,6 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
registry.register::<SageMakerTgiProvider>(false);
registry.register::<SnowflakeProvider>(false);
registry.register::<TetrateProvider>(true);
registry.register::<VeniceProvider>(false);
registry.register::<XaiProvider>(false);
});
// Register cleanup functions for providers with cached state
+1 -1
View File
@@ -54,7 +54,7 @@ pub mod tetrate;
pub mod toolshim;
pub mod usage_estimator;
pub mod utils;
pub mod venice;
pub mod xai;
pub use init::{
-566
View File
@@ -1,566 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use serde::Serialize;
use serde_json::{json, Value};
use super::api_client::{ApiClient, AuthMethod};
use super::base::{
ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, ProviderUsage, Usage,
};
use super::errors::ProviderError;
use super::openai_compatible::map_http_error_to_provider_error;
use super::retry::ProviderRetry;
use crate::conversation::message::{Message, MessageContent};
use crate::mcp_utils::ToolResult;
use crate::model::ModelConfig;
use futures::future::BoxFuture;
use rmcp::model::{object, CallToolRequestParams, Role, Tool};
// ---------- Capability Flags ----------
#[derive(Debug)]
struct CapabilityFlags(String);
impl CapabilityFlags {
fn from_json(value: &serde_json::Value) -> Self {
let caps = &value["model_spec"]["capabilities"];
let mut s = String::with_capacity(6);
macro_rules! flag {
($json_key:literal, $letter:literal) => {
if caps
.get($json_key)
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
s.push($letter);
}
};
}
flag!("optimizedForCode", 'c'); // code
flag!("supportsVision", 'v'); // vision
flag!("supportsFunctionCalling", 'f');
flag!("supportsResponseSchema", 's');
flag!("supportsWebSearch", 'w');
flag!("supportsReasoning", 'r');
CapabilityFlags(s)
}
}
impl std::fmt::Display for CapabilityFlags {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}]", self.0) // e.g. "[cvfsw]"
}
}
// ---------- END Capability Flags ----------
// ---------- Helpers ----------
/// Return the raw model id (everything before the first space).
fn strip_flags(model: &str) -> &str {
model.split_whitespace().next().unwrap_or(model)
}
// ---------- END Helpers ----------
const VENICE_PROVIDER_NAME: &str = "venice";
pub const VENICE_DOC_URL: &str = "https://docs.venice.ai/";
pub const VENICE_DEFAULT_MODEL: &str = "llama-3.3-70b";
pub const VENICE_DEFAULT_HOST: &str = "https://api.venice.ai";
pub const VENICE_DEFAULT_BASE_PATH: &str = "api/v1/chat/completions";
pub const VENICE_DEFAULT_MODELS_PATH: &str = "api/v1/models";
// Fallback models to use when API is unavailable
const FALLBACK_MODELS: [&str; 3] = [
"llama-3.2-3b", // Small model with function calling
"llama-3.3-70b", // Default model with function calling
"mistral-31-24b", // Another model with function calling
];
#[derive(Debug, Serialize)]
pub struct VeniceProvider {
#[serde(skip)]
api_client: ApiClient,
base_path: String,
models_path: String,
model: ModelConfig,
#[serde(skip)]
name: String,
}
impl VeniceProvider {
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = crate::config::Config::global();
let api_key: String = config.get_secret("VENICE_API_KEY")?;
let host: String = config
.get_param("VENICE_HOST")
.unwrap_or_else(|_| VENICE_DEFAULT_HOST.to_string());
let base_path: String = config
.get_param("VENICE_BASE_PATH")
.unwrap_or_else(|_| VENICE_DEFAULT_BASE_PATH.to_string());
let models_path: String = config
.get_param("VENICE_MODELS_PATH")
.unwrap_or_else(|_| VENICE_DEFAULT_MODELS_PATH.to_string());
let auth = AuthMethod::BearerToken(api_key);
let api_client = ApiClient::new(host, auth)?;
let instance = Self {
api_client,
base_path,
models_path,
model,
name: VENICE_PROVIDER_NAME.to_string(),
};
Ok(instance)
}
async fn post(
&self,
session_id: Option<&str>,
path: &str,
payload: &Value,
) -> Result<Value, ProviderError> {
let response = self
.api_client
.response_post(session_id, path, payload)
.await?;
let status = response.status();
tracing::debug!("Venice response status: {}", status);
if !status.is_success() {
// Read response body for more details on error
let error_body = response.text().await.unwrap_or_default();
// Log full error response for debugging
tracing::debug!("Full Venice error response: {}", error_body);
// Try to parse the error response
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_body) {
// Print the full JSON error for better debugging
println!(
"Venice API error response: {}",
serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string())
);
// Check for tool support errors
if let Some(details) = json.get("details") {
// Specifically look for tool support issues
if let Some(tools) = details.get("tools") {
if let Some(errors) = tools.get("_errors") {
if errors.to_string().contains("not supported by this model") {
let model_name = self.model.model_name.clone();
return Err(ProviderError::RequestFailed(
format!("The selected model '{}' does not support tool calls. Please select a model that supports tools, such as 'llama-3.3-70b' or 'mistral-31-24b'.", model_name)
));
}
}
}
}
// Check for specific error message in context.issues
if let Some(context) = json.get("context") {
if let Some(issues) = context.get("issues") {
if let Some(issues_array) = issues.as_array() {
for issue in issues_array {
if let Some(message) = issue.get("message").and_then(|m| m.as_str())
{
if message.contains("tools is not supported by this model") {
let model_name = self.model.model_name.clone();
return Err(ProviderError::RequestFailed(
format!("The selected model '{}' does not support tool calls. Please select a model that supports tools, such as 'llama-3.3-70b' or 'mistral-31-24b'.", model_name)
));
}
}
}
}
}
}
}
// Use the common error mapping function
let error_json = serde_json::from_str::<Value>(&error_body).ok();
return Err(map_http_error_to_provider_error(status, error_json));
}
let response_text = response.text().await?;
serde_json::from_str(&response_text).map_err(|e| {
ProviderError::RequestFailed(format!(
"Failed to parse JSON: {}\nResponse: {}",
e, response_text
))
})
}
}
impl ProviderDef for VeniceProvider {
type Provider = Self;
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
VENICE_PROVIDER_NAME,
"Venice.ai",
"Venice.ai models (Llama, DeepSeek, Mistral) with function calling",
VENICE_DEFAULT_MODEL,
FALLBACK_MODELS.to_vec(),
VENICE_DOC_URL,
vec![
ConfigKey::new("VENICE_API_KEY", true, true, None, true),
ConfigKey::new("VENICE_HOST", true, false, Some(VENICE_DEFAULT_HOST), false),
ConfigKey::new(
"VENICE_BASE_PATH",
true,
false,
Some(VENICE_DEFAULT_BASE_PATH),
false,
),
ConfigKey::new(
"VENICE_MODELS_PATH",
true,
false,
Some(VENICE_DEFAULT_MODELS_PATH),
false,
),
],
)
}
fn from_env(
model: ModelConfig,
_extensions: Vec<crate::config::ExtensionConfig>,
) -> BoxFuture<'static, Result<Self::Provider>> {
Box::pin(Self::from_env(model))
}
}
#[async_trait]
impl Provider for VeniceProvider {
fn get_name(&self) -> &str {
&self.name
}
fn get_model_config(&self) -> ModelConfig {
self.model.clone()
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
let response = self
.api_client
.request(None, &self.models_path)
.response_get()
.await?;
let json: serde_json::Value = response.json().await?;
let mut models = json["data"]
.as_array()
.ok_or_else(|| ProviderError::RequestFailed("No data field in JSON".to_string()))?
.iter()
.filter_map(|model| {
let id = model["id"].as_str()?.to_owned();
// Build flags from capabilities
let flags = CapabilityFlags::from_json(model);
// Only include models that support function calling (have 'f' flag)
if flags.0.contains('f') {
Some(format!("{id} {flags}"))
} else {
None
}
})
.collect::<Vec<String>>();
models.sort();
Ok(models)
}
async fn stream(
&self,
model_config: &ModelConfig,
session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let session_id = if session_id.is_empty() {
None
} else {
Some(session_id)
};
// Create properly formatted messages for Venice API
let mut formatted_messages = Vec::new();
// Add the system message if present
if !system.is_empty() {
formatted_messages.push(json!({
"role": "system",
"content": system
}));
}
// Format regular messages according to Venice API requirements
for msg in messages {
// Venice API expects 'content' to be a string, not an array of MessageContent
let content = match msg.role {
Role::User => {
// For user messages, concatenate all text content
let text_content: String = msg
.content
.iter()
.filter_map(|c| c.as_text())
.collect::<Vec<_>>()
.join("\n");
// If we have text content, use it directly
if !text_content.is_empty() {
text_content
} else {
// Otherwise, try to get a reasonable string representation
msg.as_concat_text()
}
}
_ => {
// For assistant messages, handle possible tool calls
let has_tool_calls = msg
.content
.iter()
.any(|c| matches!(c, MessageContent::ToolRequest(_)));
if has_tool_calls {
// If there are tool calls, we'll handle them separately
// Just use an empty string for content
"".to_string()
} else {
// Otherwise use text content
msg.as_concat_text()
}
}
};
// Create basic message with content as string
let mut venice_msg = json!({
"role": match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
},
"content": content
});
// Add debug information to tracing
tracing::debug!(
"Venice message format: role={:?}, content_len={}, has_tool_calls={}",
msg.role,
content.len(),
msg.content
.iter()
.any(|c| matches!(c, MessageContent::ToolRequest(_)))
);
// For assistant messages with tool calls, add them in Venice format
if msg.role == Role::Assistant {
let tool_calls: Vec<_> = msg
.content
.iter()
.filter_map(|c| c.as_tool_request())
.collect();
if !tool_calls.is_empty() {
// Transform our tool calls to Venice format
let venice_tool_calls: Vec<Value> = tool_calls
.iter()
.filter_map(|tr| {
if let ToolResult::Ok(tool_call) = &tr.tool_call {
// Safely convert arguments to a JSON string
let args_str = tool_call
.arguments
.as_ref() // borrow the Option contents
.map(|map| serde_json::to_string(map).unwrap_or_default())
.unwrap_or_default();
// Log tool call details for debugging
tracing::debug!(
"Tool call conversion: id={}, name={}, args_len={}",
tr.id,
tool_call.name,
args_str.len()
);
// Convert to Venice format
Some(json!({
"id": tr.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": args_str
}
}))
} else {
tracing::warn!("Skipping tool call with error: id={}", tr.id);
None
}
})
.collect();
if !venice_tool_calls.is_empty() {
tracing::debug!("Adding {} tool calls to message", venice_tool_calls.len());
venice_msg["tool_calls"] = json!(venice_tool_calls);
}
}
}
// For tool messages with tool responses, add required tool_call_id
// Check for tool responses regardless of role - they should have an ID
// that corresponds to the tool call they're responding to
{
let tool_responses: Vec<_> = msg
.content
.iter()
.filter_map(|c| c.as_tool_response())
.collect();
if !tool_responses.is_empty() && !tool_responses[0].id.is_empty() {
venice_msg["tool_call_id"] = json!(tool_responses[0].id);
// Venice expects tool messages to have 'role' = 'tool'
venice_msg["role"] = json!("tool");
}
}
formatted_messages.push(venice_msg);
}
// Build Venice-specific payload
let mut payload = json!({
"model": strip_flags(&model_config.model_name),
"messages": formatted_messages,
"stream": false,
"temperature": 0.7,
"max_tokens": 2048,
});
if !tools.is_empty() {
// Format tools specifically for Venice API
let formatted_tools: Vec<serde_json::Value> = tools
.iter()
.map(|tool| {
// Format each tool in the expected Venice format
json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema
}
})
})
.collect();
payload["tools"] = json!(formatted_tools);
}
tracing::debug!("Sending request to Venice API");
tracing::debug!("Venice request payload: {}", payload.to_string());
// Send request with retry
let response = self
.with_retry(|| self.post(session_id, &self.base_path, &payload))
.await?;
// Parse the response - response is already a Value from our post method
let response_json = response;
// Handle tool calls from the response if present
let tool_calls = response_json["choices"]
.get(0)
.and_then(|choice| choice["message"]["tool_calls"].as_array());
if let Some(tool_calls) = tool_calls {
if !tool_calls.is_empty() {
// Extract tool calls and format for our internal model
let mut content = Vec::new();
for tool_call in tool_calls {
let id = tool_call["id"].as_str().unwrap_or("unknown").to_string();
let function = tool_call["function"].clone();
let name = function["name"].as_str().unwrap_or("unknown").to_string();
// Parse arguments string to Value if it's a string
let arguments = if let Some(args_str) = function["arguments"].as_str() {
serde_json::from_str::<Value>(args_str)
.unwrap_or(function["arguments"].clone())
} else {
function["arguments"].clone()
};
let tool_call =
CallToolRequestParams::new(name).with_arguments(object(arguments));
// Create a ToolRequest MessageContent
let tool_request = MessageContent::tool_request(id, ToolResult::Ok(tool_call));
content.push(tool_request);
}
// Create message and add each content item
let mut message = Message::assistant();
for item in content {
message = message.with_content(item);
}
let provider_usage = ProviderUsage::new(
strip_flags(&model_config.model_name).to_string(),
Usage::default(),
);
return Ok(super::base::stream_from_single_message(
message,
provider_usage,
));
}
}
// If we get here, it's a regular text response
// Extract content
let content = response_json["choices"]
.get(0)
.and_then(|choice| choice["message"]["content"].as_str())
.ok_or_else(|| {
tracing::error!("Invalid response format: {:?}", response_json);
ProviderError::RequestFailed("Invalid response format: missing content".to_string())
})?
.to_string();
// Create a vector with a single text content item
let content = vec![MessageContent::text(content)];
// Extract usage
let usage_data = &response_json["usage"];
let usage = Usage::new(
usage_data["prompt_tokens"].as_i64().map(|v| v as i32),
usage_data["completion_tokens"].as_i64().map(|v| v as i32),
usage_data["total_tokens"].as_i64().map(|v| v as i32),
);
let message = Message::new(Role::Assistant, Utc::now().timestamp(), content);
let provider_usage =
ProviderUsage::new(strip_flags(&self.model.model_name).to_string(), usage);
Ok(super::base::stream_from_single_message(
message,
provider_usage,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metadata_structure() {
let metadata = VeniceProvider::metadata();
assert_eq!(metadata.default_model, "llama-3.3-70b");
assert!(!metadata.known_models.is_empty());
assert_eq!(metadata.config_keys.len(), 4);
assert_eq!(metadata.config_keys[0].name, "VENICE_API_KEY");
assert_eq!(metadata.config_keys[1].name, "VENICE_HOST");
assert_eq!(metadata.config_keys[2].name, "VENICE_BASE_PATH");
assert_eq!(metadata.config_keys[3].name, "VENICE_MODELS_PATH");
}
}