Files
windmill/backend/windmill-worker/src/ai/utils.rs
centdix 825df2161e refactor: extract google ai logic to windmill-common and use native gemini api in chat proxy (#8115)
* refactor: extract google ai logic to windmill-common and use native gemini api in chat proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use x-goog-api-key header for google ai non-chat requests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: transform gemini models response to openai format and use correct auth header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: skip thought parts from gemini thinking models in sse stream

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revert "fix: skip thought parts from gemini thinking models in sse stream"

This reverts commit dfa01d282c.

* fix: handle tool calls and sanitize schemas in gemini chat proxy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: move Gemini→OpenAI response conversion to windmill-common

Extract streaming and non-streaming Gemini response conversion into
shared functions in ai_google so the API proxy and worker use the same
logic instead of duplicating format translation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: review fixes for google ai refactor

- Remove duplicate parse_data_url from worker utils, use shared version
  from windmill_common::ai_google in both google_ai and anthropic providers
- Improve error diagnostics in google.rs by including HTTP status code
  in error messages from Gemini API responses
- Change GeminiToolCallEvent::into_extra_content to instance method
  to_extra_content using &self

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: deduplicate worker Gemini message conversion using pre-flight pattern

Replace the worker's `convert_messages_to_gemini` and
`convert_content_to_parts_with_s3` (~130 lines) with the existing
pre-flight pattern: `prepare_messages_for_api` converts S3 objects to
data URLs, then the shared `openai_messages_to_gemini` handles the rest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
2026-03-09 15:15:37 +00:00

734 lines
23 KiB
Rust

pub use crate::ai::types::McpToolSource;
use crate::ai::types::ToolDef;
use anyhow::Context;
use serde_json::value::RawValue;
use sqlx::types::Json;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use uuid::Uuid;
use windmill_common::flows::FlowModuleValue;
use windmill_common::{
ai_providers::AIProvider,
db::DB,
error::Error,
flow_conversations::{add_message_to_conversation_tx, MessageType},
flow_status::AgentAction,
flows::{InputTransform, Step},
jobs::JobKind,
scripts::{ScriptHash, ScriptLang},
worker::to_raw_value,
};
#[cfg(feature = "mcp")]
use windmill_mcp::{McpClient, McpResource, McpTool};
use windmill_queue::{flow_status::get_step_of_flow_status, MiniPulledJob};
use crate::{ai::types::*, parse_sig_of_lang};
pub fn parse_raw_script_schema(
content: &str,
language: &ScriptLang,
) -> Result<Box<RawValue>, Error> {
let main_arg_signature = parse_sig_of_lang(content, Some(&language), None)?
.ok_or_else(|| Error::BadConfig(format!(
"Cannot parse signature for language {:?}. The language parser may not be enabled in this build.",
language
)))?;
let schema = OpenAPISchema {
r#type: Some(SchemaType::default()),
properties: Some(
main_arg_signature
.args
.iter()
.map(|arg| {
let name = arg.name.clone();
let typ = OpenAPISchema::from_typ(&arg.typ);
(name, Box::new(typ))
})
.collect(),
),
required: Some(
main_arg_signature
.args
.iter()
.map(|arg| arg.name.clone())
.collect(),
),
..Default::default()
};
Ok(to_raw_value(&schema))
}
pub fn is_completed_input_transform(transform: &InputTransform) -> bool {
match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
}
}
/// Filters out properties from a JSON schema that have completed input transforms.
/// This allows AI agents to only see and fill parameters that don't have user-configured values.
pub fn filter_schema_by_input_transforms(
schema: Box<RawValue>,
input_transforms: &HashMap<String, InputTransform>,
) -> Result<Box<RawValue>, Error> {
// Parse the schema JSON
let mut schema_value: serde_json::Value = serde_json::from_str(schema.get())
.context("Failed to parse schema JSON")
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
// Collect keys to remove (parameters with completed input transforms)
let keys_to_remove: HashSet<String> = input_transforms
.iter()
.filter_map(|(key, transform)| {
let is_completed = is_completed_input_transform(transform);
if is_completed {
Some(key.clone())
} else {
None
}
})
.collect();
if !keys_to_remove.is_empty() {
// Remove completed parameters from properties
if let Some(properties) = schema_value
.get_mut("properties")
.and_then(|p| p.as_object_mut())
{
for key in &keys_to_remove {
properties.remove(key);
}
}
// Also remove from required array
if let Some(required) = schema_value
.get_mut("required")
.and_then(|r| r.as_array_mut())
{
required.retain(|item| {
if let Some(key) = item.as_str() {
!keys_to_remove.contains(key)
} else {
true
}
});
}
}
// Convert back to RawValue
Ok(to_raw_value(&schema_value))
}
#[derive(Clone)]
pub struct FlowJobRunnableIdAndRawFlow {
pub runnable_id: Option<ScriptHash>,
pub raw_flow: Option<sqlx::types::Json<Box<RawValue>>>,
pub kind: JobKind,
pub parent_job: Option<Uuid>,
pub flow_step_id: Option<String>,
}
pub async fn get_flow_job_runnable_and_raw_flow(
db: &DB,
job_id: &uuid::Uuid,
) -> windmill_common::error::Result<FlowJobRunnableIdAndRawFlow> {
let job = sqlx::query_as!(
FlowJobRunnableIdAndRawFlow,
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
job_id
)
.fetch_one(db)
.await?;
Ok(job)
}
#[derive(Debug, Clone, Default)]
pub struct FlowContext {
pub flow_inputs: Option<HashMap<String, Box<RawValue>>>,
pub flow_status: Option<windmill_common::flow_status::FlowStatus>,
}
/// Get flow context (chat settings + args + flow_status) from root flow's job data
pub async fn get_flow_context(db: &DB, job: &MiniPulledJob) -> FlowContext {
let root_job_id = job
.root_job
.or(job.flow_innermost_root_job)
.or(job.parent_job);
let Some(root_job_id) = root_job_id else {
return FlowContext::default();
};
match sqlx::query!(
r#"
SELECT
j.args as "args: Json<HashMap<String, Box<RawValue>>>",
js.flow_status as "flow_status: Json<windmill_common::flow_status::FlowStatus>"
FROM v2_job_status js
INNER JOIN v2_job j ON j.id = js.id
WHERE js.id = $1
"#,
root_job_id
)
.fetch_optional(db)
.await
{
Ok(Some(row)) => FlowContext {
flow_inputs: row.args.map(|j| j.0),
flow_status: row.flow_status.map(|j| j.0),
},
Ok(None) => {
tracing::warn!(
"No flow context found for root job {} (agent job {}), returning default",
root_job_id,
job.id
);
FlowContext::default()
}
Err(e) => {
tracing::error!("Failed to get flow context for job {}: {}", job.id, e);
FlowContext::default()
}
}
}
// Add message to conversation
pub async fn add_message_to_conversation(
db: &DB,
conversation_id: &Uuid,
job_id: Option<Uuid>,
message_content: &str,
message_type: MessageType,
step_name: &Option<String>,
success: bool,
) -> Result<(), Error> {
let mut tx = db.begin().await?;
add_message_to_conversation_tx(
&mut tx,
*conversation_id,
job_id,
&message_content,
message_type,
step_name.as_deref(),
success,
)
.await?;
tx.commit().await?;
Ok(())
}
/// Find a unique tool name for structured output tool to avoid collisions with user-provided tools
pub fn find_unique_tool_name(base_name: &str, existing_tools: Option<&[ToolDef]>) -> String {
let Some(tools) = existing_tools else {
return base_name.to_string();
};
if !tools.iter().any(|t| t.function.name == base_name) {
return base_name.to_string();
}
for i in 1..100 {
let candidate = format!("{}_{}", base_name, i);
if !tools.iter().any(|t| t.function.name == candidate) {
return candidate;
}
}
// Fallback with process id if somehow we can't find a unique name
format!("{}_{}_fallback", base_name, std::process::id())
}
pub async fn update_flow_status_module_with_actions(
db: &DB,
parent_job: &Uuid,
actions: &[AgentAction],
) -> Result<(), Error> {
let step = get_step_of_flow_status(db, parent_job.to_owned()).await?;
match step {
Step::Step { idx: step, .. } => {
sqlx::query!(
r#"
UPDATE v2_job_status SET
flow_status = jsonb_set(
flow_status,
array['modules', $3::TEXT, 'agent_actions'],
$2
)
WHERE id = $1
"#,
parent_job,
sqlx::types::Json(actions) as _,
step as i32
)
.execute(db)
.await?;
}
_ => {}
}
Ok(())
}
pub async fn update_flow_status_module_with_actions_success(
db: &DB,
parent_job: &Uuid,
action_success: bool,
) -> Result<(), Error> {
let step = get_step_of_flow_status(db, parent_job.to_owned()).await?;
match step {
Step::Step { idx: step, .. } => {
// Append the new bool to the existing array, or create a new array if it doesn't exist
sqlx::query!(
r#"
UPDATE v2_job_status SET
flow_status = jsonb_set(
flow_status,
array['modules', $2::TEXT, 'agent_actions_success'],
COALESCE(
flow_status->'modules'->$2->'agent_actions_success',
to_jsonb(ARRAY[]::bool[])
) || to_jsonb(ARRAY[$3::bool])
)
WHERE id = $1
"#,
parent_job,
step as i32,
action_success
)
.execute(db)
.await?;
}
_ => {}
}
Ok(())
}
/// Get step name from the flow module (summary if exists, else id)
pub fn get_step_name_from_flow(
summary: Option<&str>,
flow_step_id: Option<&str>,
) -> Option<String> {
let flow_step_id = flow_step_id?;
Some(
summary
.map(|s| s.to_string())
.unwrap_or_else(|| format!("AI Agent Step {}", flow_step_id)),
)
}
/// AWS Bedrock do not handle structured output query param, so we use a tool for structured output. Same for every Claude models.
pub fn should_use_structured_output_tool(provider: &AIProvider, model: &str) -> bool {
model.contains("claude") || provider == &AIProvider::AWSBedrock
}
/// Cleanup MCP clients by gracefully shutting down connections
#[cfg(feature = "mcp")]
pub async fn cleanup_mcp_clients(mcp_clients: HashMap<String, Arc<McpClient>>) {
if mcp_clients.is_empty() {
return;
}
tracing::debug!("Cleaning up {} MCP client(s)", mcp_clients.len());
for (resource_name, client) in mcp_clients {
// Try to unwrap the Arc to get the McpClient
match Arc::try_unwrap(client) {
Ok(client) => {
tracing::debug!("Shutting down MCP client for {}", resource_name);
if let Err(e) = client.shutdown().await {
tracing::warn!("Failed to shutdown MCP client for {}: {}", resource_name, e);
}
}
Err(arc) => {
// Other references still exist (shouldn't happen in normal flow)
tracing::warn!(
"MCP client for {} still has {} references, dropping without graceful shutdown",
resource_name,
Arc::strong_count(&arc)
);
}
}
}
}
#[cfg(feature = "mcp")]
fn sanitize_tool_name_part(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
/// Convert raw MCP tools to Windmill Tool format with source tracking
#[cfg(feature = "mcp")]
fn convert_mcp_tools_to_windmill_tools(
mcp_tools: &[McpTool],
resource_name: &str,
resource_path: &str,
) -> Result<Vec<Tool>, Error> {
mcp_tools
.iter()
.map(|mcp_tool| {
let sanitized_resource_name = sanitize_tool_name_part(resource_name);
let tool_name = format!("mcp_{}_{}", sanitized_resource_name, mcp_tool.name);
let mut schema_value = serde_json::to_value(&*mcp_tool.input_schema)
.context("Failed to convert MCP schema to JSON value")?;
McpClient::fix_array_schemas(&mut schema_value);
let parameters = to_raw_value(&schema_value);
// Build the description from title and description
let description = if let Some(title) = &mcp_tool.title {
if let Some(desc) = &mcp_tool.description {
Some(format!("{}: {}", title, desc))
} else {
Some(title.to_string())
}
} else {
mcp_tool.description.as_ref().map(|d| d.to_string())
};
let tool_def_function =
ToolDefFunction { name: tool_name.clone(), description, parameters };
let tool_def = ToolDef { r#type: "function".to_string(), function: tool_def_function };
Ok(Tool {
def: tool_def,
module: None,
mcp_source: Some(McpToolSource {
name: resource_name.to_string(),
tool_name: mcp_tool.name.to_string(),
resource_path: resource_path.to_string(),
}),
})
})
.collect()
}
/// Configuration for loading tools from an MCP server resource
#[cfg(feature = "mcp")]
#[derive(Debug, Clone)]
pub struct McpResourceConfig {
pub resource_path: String,
pub include_tools: Option<Vec<String>>,
pub exclude_tools: Option<Vec<String>>,
}
/// Apply include/exclude filters to a list of tools
/// Priority: include_tools > exclude_tools > all
/// - If include_tools is Some and non-empty: whitelist approach (keep only listed tools)
/// - Else if exclude_tools is Some and non-empty: blacklist approach (remove listed tools)
/// - Otherwise: no filtering (keep all tools)
#[cfg(feature = "mcp")]
fn apply_tool_filters(
tools: Vec<Tool>,
include_tools: &Option<Vec<String>>,
exclude_tools: &Option<Vec<String>>,
) -> Vec<Tool> {
// If include_tools is specified and non-empty, use whitelist approach
if let Some(include_list) = include_tools {
if !include_list.is_empty() {
return tools
.into_iter()
.filter(|tool| {
tool.mcp_source
.as_ref()
.map(|src| include_list.contains(&src.tool_name))
.unwrap_or(false)
})
.collect();
}
}
// If exclude_tools is specified and non-empty, use blacklist approach
if let Some(exclude_list) = exclude_tools {
if !exclude_list.is_empty() {
return tools
.into_iter()
.filter(|tool| {
tool.mcp_source
.as_ref()
.map(|src| !exclude_list.contains(&src.tool_name))
.unwrap_or(true)
})
.collect();
}
}
// No filtering - return all tools
tools
}
/// Check if a token variable is expired and refresh it if needed via API call
#[cfg(feature = "mcp")]
async fn refresh_token_if_expired(
db: &DB,
workspace_id: &str,
token_path: &str,
auth_token: &str,
) -> Result<(), Error> {
// Query variable with account join to check expiration
let token_info = sqlx::query!(
r#"
SELECT
variable.path,
variable.account as account_id,
(now() > account.expires_at) as "is_expired: bool"
FROM variable
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2
WHERE variable.path = $1 AND variable.workspace_id = $2
"#,
token_path,
workspace_id
)
.fetch_optional(db)
.await?;
let Some(token_info) = token_info else {
return Ok(());
};
let Some(account_id) = token_info.account_id else {
return Ok(());
};
if !token_info.is_expired.unwrap_or(false) {
return Ok(());
}
tracing::debug!(
"Token variable {} is expired, triggering refresh",
token_path
);
// Call the API refresh endpoint
let base_url = windmill_common::BASE_URL.read().await.clone();
let refresh_url = format!(
"{}/api/w/{}/oauth/refresh_token/{}",
base_url, workspace_id, account_id
);
#[derive(serde::Serialize)]
struct RefreshRequest {
path: String,
}
let response = windmill_common::utils::HTTP_CLIENT
.post(&refresh_url)
.header("Authorization", format!("Bearer {}", auth_token))
.json(&RefreshRequest { path: token_path.to_string() })
.send()
.await
.map_err(|e| {
Error::internal_err(format!("Failed to call token refresh endpoint: {}", e))
})?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::internal_err(format!(
"Token refresh failed: {}",
error_text
)));
}
Ok(())
}
/// Load tools from MCP servers and return both the clients and tools
/// Returns a map of resource name -> client, and a vector of tools
#[cfg(feature = "mcp")]
pub async fn load_mcp_tools(
db: &DB,
workspace_id: &str,
mcp_configs: Vec<McpResourceConfig>,
auth_token: &str,
) -> Result<(HashMap<String, Arc<McpClient>>, Vec<Tool>), Error> {
let mut all_mcp_tools = Vec::new();
let mut mcp_clients = HashMap::new();
for config in mcp_configs {
tracing::debug!("Loading MCP tools from resource: {}", config.resource_path);
let path = config.resource_path.trim_start_matches("$res:");
let mcp_resource = {
// Fetch the resource from database
let resource= sqlx::query_scalar!(
"SELECT value as \"value: sqlx::types::Json<Box<RawValue>>\" FROM resource WHERE path = $1 AND workspace_id = $2",
&path,
&workspace_id
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", config.resource_path)))?
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", config.resource_path)))?;
serde_json::from_str::<McpResource>(resource.0.get())
.context("Failed to parse MCP resource")?
};
let resource_name = mcp_resource.name.clone();
// Check if token needs refresh before creating MCP client
if let Some(ref token_path) = mcp_resource.token {
let token_var_path = token_path.trim_start_matches("$var:");
if let Err(e) =
refresh_token_if_expired(db, workspace_id, token_var_path, auth_token).await
{
tracing::warn!(
"Failed to refresh token for MCP resource {}: {}. Proceeding with possibly expired token.",
resource_name, e
);
}
}
// Create new MCP client for this execution
tracing::debug!("Creating fresh MCP client for {}", resource_name);
let client = McpClient::from_resource(mcp_resource, db, workspace_id)
.await
.context("Failed to create MCP client")?;
// Get raw MCP tools from client
let raw_mcp_tools = client.available_tools();
// Convert to Windmill Tool format
let converted_tools =
convert_mcp_tools_to_windmill_tools(raw_mcp_tools, &resource_name, &path)?;
// Apply include/exclude filters
let filtered_tools = apply_tool_filters(
converted_tools,
&config.include_tools,
&config.exclude_tools,
);
tracing::info!(
"Loaded {} tools from MCP server '{}' (filtered from {} available tools)",
filtered_tools.len(),
resource_name,
raw_mcp_tools.len()
);
all_mcp_tools.extend(filtered_tools);
// Store client for later use and cleanup
let mcp_client = Arc::new(client);
mcp_clients.insert(resource_name, mcp_client);
}
Ok((mcp_clients, all_mcp_tools))
}
/// Execute an MCP tool by routing the call to the appropriate MCP client
#[cfg(feature = "mcp")]
pub async fn execute_mcp_tool(
mcp_clients: &HashMap<String, Arc<McpClient>>,
mcp_source: &McpToolSource,
arguments_str: &str,
) -> Result<serde_json::Value, Error> {
// Get the MCP client from the provided map
let mcp_client = mcp_clients.get(&mcp_source.name).ok_or_else(|| {
Error::internal_err(format!(
"MCP client not found for resource: {}",
mcp_source.name
))
})?;
// Call the MCP tool
let result = mcp_client
.call_tool(&mcp_source.tool_name, arguments_str)
.await
.context("MCP tool call failed")?;
Ok(result)
}
// Stub implementations when mcp feature is not enabled
#[cfg(not(feature = "mcp"))]
pub struct McpResourceConfig {}
/// Stub for cleanup_mcp_clients when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn cleanup_mcp_clients<T>(_mcp_clients: HashMap<String, Arc<T>>) {
// No-op when MCP is disabled
}
/// Stub for load_mcp_tools when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn load_mcp_tools<T>(
_db: &DB,
_workspace_id: &str,
_mcp_configs: Vec<McpResourceConfig>,
_auth_token: &str,
) -> Result<(HashMap<String, Arc<T>>, Vec<Tool>), Error> {
Ok((HashMap::new(), Vec::new()))
}
/// Stub for execute_mcp_tool when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn execute_mcp_tool<T>(
_mcp_clients: &HashMap<String, Arc<T>>,
mcp_source: &McpToolSource,
_arguments_str: &str,
) -> Result<serde_json::Value, Error> {
Err(Error::internal_err(format!(
"MCP support is not enabled. Cannot execute MCP tool: {}",
mcp_source.tool_name
)))
}
/// Check if any tool's input transforms reference previous_result
pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
tools.iter().any(|tool| {
if let Some(module) = &tool.module {
if let Ok(module_value) = module.get_value() {
let input_transforms = match module_value {
FlowModuleValue::Script { input_transforms, .. } => input_transforms,
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
_ => return false,
};
return input_transforms.iter().any(|(_, transform)| {
if let windmill_common::flows::InputTransform::Javascript { expr } = transform {
expr.contains("previous_result")
} else {
false
}
});
}
}
false
})
}
/// Extract text content from OpenAIContent, joining parts with space if multiple
pub fn extract_text_content(content: &OpenAIContent) -> String {
match content {
OpenAIContent::Text(text) => text.clone(),
OpenAIContent::Parts(parts) => parts
.iter()
.filter_map(|p| {
if let ContentPart::Text { text } = p {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join(""),
}
}