From a7d85a39ff177834e87b7baf444ed19179a14ca9 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 28 May 2026 18:05:04 +0200 Subject: [PATCH 01/11] refactor: clean up ai provider proxy logic (#9360) * refactor: clean up ai provider proxy logic * docs: remove completed ai refactor plan * fix: audit failed google global proxy calls --- backend/windmill-ai/src/ai_bedrock.rs | 36 ++ backend/windmill-ai/src/credentials.rs | 25 + backend/windmill-ai/src/lib.rs | 1 + .../windmill-ai/src/providers/anthropic.rs | 3 +- backend/windmill-ai/src/providers/bedrock.rs | 356 +++++++++----- .../windmill-ai/src/providers/google_ai.rs | 2 +- backend/windmill-ai/src/providers/mod.rs | 4 +- backend/windmill-ai/src/proxy.rs | 28 +- backend/windmill-ai/src/proxy/fim.rs | 120 +++++ backend/windmill-ai/src/types.rs | 2 +- backend/windmill-api/src/ai.rs | 188 ++++---- backend/windmill-worker/src/ai/mod.rs | 2 +- ...y_builder.rs => stream_event_processor.rs} | 0 backend/windmill-worker/src/ai/tools.rs | 2 +- backend/windmill-worker/src/ai_executor.rs | 2 +- docs/windmill-ai-refactor-plan.md | 441 ------------------ 16 files changed, 510 insertions(+), 702 deletions(-) create mode 100644 backend/windmill-ai/src/credentials.rs create mode 100644 backend/windmill-ai/src/proxy/fim.rs rename backend/windmill-worker/src/ai/{query_builder.rs => stream_event_processor.rs} (100%) delete mode 100644 docs/windmill-ai-refactor-plan.md diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index c05094b8f4..e549e63041 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -754,6 +754,26 @@ pub fn bedrock_stream_event_to_tool_start( } } +pub fn bedrock_stream_event_to_tool_start_with_block_index( + event: &ConverseStreamOutput, +) -> Option<(usize, StreamingToolCall)> { + match event { + ConverseStreamOutput::ContentBlockStart(start) => { + let block_index = usize::try_from(start.content_block_index()).ok()?; + let tool_use = start.start().and_then(|s| s.as_tool_use().ok())?; + Some(( + block_index, + StreamingToolCall { + id: tool_use.tool_use_id().to_string(), + name: tool_use.name().to_string(), + arguments: String::new(), + }, + )) + } + _ => None, + } +} + /// Extract tool use input delta from stream pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Option { match event { @@ -765,6 +785,22 @@ pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Optio } } +pub fn bedrock_stream_event_to_tool_delta_with_block_index( + event: &ConverseStreamOutput, +) -> Option<(usize, String)> { + match event { + ConverseStreamOutput::ContentBlockDelta(delta) => { + let block_index = usize::try_from(delta.content_block_index()).ok()?; + let input = delta + .delta() + .and_then(|d| d.as_tool_use().ok()) + .map(|tool_use| tool_use.input().to_string())?; + Some((block_index, input)) + } + _ => None, + } +} + /// Check if stream event indicates content block stop pub fn bedrock_stream_event_is_block_stop(event: &ConverseStreamOutput) -> bool { matches!(event, ConverseStreamOutput::ContentBlockStop(_)) diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs new file mode 100644 index 0000000000..75d523cf25 --- /dev/null +++ b/backend/windmill-ai/src/credentials.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; + +use crate::ai_providers::{AIPlatform, AIProvider}; + +/// Resolved provider credentials shared by API proxy and worker execution. +/// +/// Raw API resources and worker agent payloads convert into this shape at their +/// execution boundaries. Request-specific state such as the selected model stays +/// outside this type. +#[derive(Clone, Debug)] +pub struct ProviderCredentials { + pub provider: AIProvider, + pub base_url: String, + pub api_key: Option, + pub access_token: Option, + pub organization_id: Option, + pub user: Option, + pub region: Option, + pub aws_access_key_id: Option, + pub aws_secret_access_key: Option, + pub aws_session_token: Option, + pub platform: AIPlatform, + pub enable_1m_context: bool, + pub custom_headers: HashMap, +} diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index a138d72f3c..b6487c0ac5 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -4,6 +4,7 @@ pub mod ai_cache; pub mod ai_google; pub mod ai_providers; pub mod ai_types; +pub mod credentials; pub mod image_handler; pub mod providers; pub mod proxy; diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 8a8a2b846d..17a27e370f 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -729,8 +729,7 @@ impl QueryBuilder for AnthropicQueryBuilder { mod tests { use super::*; use crate::{ - proxy::{ProviderCredentials, ProxyBuildArgs}, - query_builder::QueryBuilder, + credentials::ProviderCredentials, proxy::ProxyBuildArgs, query_builder::QueryBuilder, }; use http::{HeaderMap, HeaderValue, Method}; use std::collections::HashMap; diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index ec6f4fbcd3..0eef359c36 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -10,9 +10,10 @@ use crate::{ ai_bedrock::{ bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, - bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config, - format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai, - BearerTokenProvider, BedrockClient, StreamingToolCall, + bedrock_stream_event_to_tool_delta_with_block_index, bedrock_stream_event_to_tool_start, + bedrock_stream_event_to_tool_start_with_block_index, build_tool_config, + create_inference_config, format_bedrock_error, openai_messages_to_bedrock, + streaming_tool_calls_to_openai, BearerTokenProvider, BedrockClient, StreamingToolCall, }, ai_providers::USE_ENV_REGION, ai_types::{OpenAIFunction, OpenAIToolCall, ToolDefFunction}, @@ -403,137 +404,15 @@ pub fn sdk_stream_to_sse( .unwrap() .as_secs(); - struct StreamState { - id: String, - model: String, - created: u64, - tool_calls: HashMap, - current_tool_index: usize, - } - - let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState { - id, - model, - created, - tool_calls: HashMap::new(), - current_tool_index: 0, - })); - async_stream::stream! { let mut stream = stream; - let state = state.clone(); + let mut state = BedrockSseStreamState::new(id, model, created); loop { match stream.recv().await { Ok(Some(event)) => { - let mut state = state.lock().await; - - if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) { - let index = state.current_tool_index; - state.tool_calls.insert( - index, - (tool_call.id.clone(), tool_call.name.clone(), String::new()), - ); - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": index, - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.name, - "arguments": "" - } - }] - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); - } - - if let Some(text) = bedrock_stream_event_to_text(&event) { - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "content": text - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); - } - - if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) { - let index = state.current_tool_index; - if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) { - args.push_str(&input_delta); - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": index, - "function": { - "arguments": input_delta - } - }] - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); - } - } - - if bedrock_stream_event_is_block_stop(&event) { - state.current_tool_index += 1; - } - - if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event { - let stop_reason = stop.stop_reason().as_str(); - let finish_reason = match stop_reason { - "end_turn" => "stop", - "max_tokens" => "length", - "tool_use" => "tool_calls", - "stop_sequence" => "stop", - "guardrail_intervened" | "content_filtered" => "content_filter", - _ => "stop", - }; - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": finish_reason - }] - }); - - yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); + for chunk in bedrock_sse_chunks_for_event(&event, &mut state) { + yield Ok(chunk); } } Ok(None) => break, @@ -551,6 +430,149 @@ pub fn sdk_stream_to_sse( } } +#[derive(Debug)] +struct BedrockSseStreamState { + id: String, + model: String, + created: u64, + tool_calls: HashMap, + tool_block_indexes: HashMap, + next_tool_index: usize, +} + +impl BedrockSseStreamState { + fn new(id: String, model: String, created: u64) -> Self { + Self { + id, + model, + created, + tool_calls: HashMap::new(), + tool_block_indexes: HashMap::new(), + next_tool_index: 0, + } + } +} + +fn bedrock_sse_chunks_for_event( + event: &aws_sdk_bedrockruntime::types::ConverseStreamOutput, + state: &mut BedrockSseStreamState, +) -> Vec { + let mut chunks = Vec::new(); + + if let Some((block_index, tool_call)) = + bedrock_stream_event_to_tool_start_with_block_index(event) + { + let index = state.next_tool_index; + state.next_tool_index += 1; + state.tool_block_indexes.insert(block_index, index); + state.tool_calls.insert( + index, + (tool_call.id.clone(), tool_call.name.clone(), String::new()), + ); + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": "" + } + }] + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some(text) = bedrock_stream_event_to_text(event) { + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "content": text + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some((block_index, input_delta)) = + bedrock_stream_event_to_tool_delta_with_block_index(event) + { + if let Some(index) = state.tool_block_indexes.get(&block_index).copied() { + if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) { + args.push_str(&input_delta); + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "function": { + "arguments": input_delta + } + }] + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + } + } + + if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = event { + let stop_reason = stop.stop_reason().as_str(); + let finish_reason = match stop_reason { + "end_turn" => "stop", + "max_tokens" => "length", + "tool_use" => "tool_calls", + "stop_sequence" => "stop", + "guardrail_intervened" | "content_filtered" => "content_filter", + _ => "stop", + }; + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": finish_reason + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + chunks +} + async fn handle_bedrock_sdk_non_streaming( model: &str, body: &[u8], @@ -970,6 +992,19 @@ impl BedrockQueryBuilder { #[cfg(test)] mod tests { use super::*; + use aws_sdk_bedrockruntime::types::{ + ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart, ContentBlockStartEvent, + ContentBlockStopEvent, ConverseStreamOutput, ToolUseBlockDelta, ToolUseBlockStart, + }; + + fn sse_json(chunk: &Bytes) -> serde_json::Value { + let chunk = std::str::from_utf8(chunk).expect("SSE chunk should be UTF-8"); + let payload = chunk + .strip_prefix("data: ") + .and_then(|chunk| chunk.strip_suffix("\n\n")) + .expect("chunk should be SSE data"); + serde_json::from_str(payload).expect("chunk should contain JSON") + } #[test] fn determine_auth_config_prioritizes_bearer_token() { @@ -1022,4 +1057,69 @@ mod tests { let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token")); assert!(matches!(config, BedrockAuthConfig::Environment)); } + + #[test] + fn bedrock_sse_tool_indexes_ignore_text_block_stops() { + let mut state = + BedrockSseStreamState::new("chatcmpl-test".to_string(), "model".to_string(), 1); + + let text_delta = ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(0) + .delta(ContentBlockDelta::Text("hello".to_string())) + .build() + .unwrap(), + ); + assert_eq!( + bedrock_sse_chunks_for_event(&text_delta, &mut state).len(), + 1 + ); + + let text_stop = ConverseStreamOutput::ContentBlockStop( + ContentBlockStopEvent::builder() + .content_block_index(0) + .build() + .unwrap(), + ); + assert!(bedrock_sse_chunks_for_event(&text_stop, &mut state).is_empty()); + + let tool_start = ConverseStreamOutput::ContentBlockStart( + ContentBlockStartEvent::builder() + .content_block_index(1) + .start(ContentBlockStart::ToolUse( + ToolUseBlockStart::builder() + .tool_use_id("call_1") + .name("lookup") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let start_chunks = bedrock_sse_chunks_for_event(&tool_start, &mut state); + let start_json = sse_json(&start_chunks[0]); + assert_eq!( + start_json["choices"][0]["delta"]["tool_calls"][0]["index"], + 0 + ); + + let tool_delta = ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(1) + .delta(ContentBlockDelta::ToolUse( + ToolUseBlockDelta::builder() + .input("{\"city\":\"Paris\"}") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let delta_chunks = bedrock_sse_chunks_for_event(&tool_delta, &mut state); + let delta_json = sse_json(&delta_chunks[0]); + assert_eq!( + delta_json["choices"][0]["delta"]["tool_calls"][0]["index"], + 0 + ); + } } diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 57abae5182..cf00ddc142 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -691,7 +691,7 @@ impl QueryBuilder for GoogleAIQueryBuilder { #[cfg(test)] mod tests { use super::*; - use crate::{ai_providers::AIProvider, proxy::ProviderCredentials}; + use crate::{ai_providers::AIProvider, credentials::ProviderCredentials}; use std::collections::HashMap; fn credentials(base_url: &str, platform: AIPlatform) -> ProviderCredentials { diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index f3f4a37478..fb50707221 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -6,7 +6,9 @@ pub mod openai; pub mod openrouter; pub mod other; -use crate::{ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder}; +use crate::{ + ai_providers::AIProvider, credentials::ProviderCredentials, query_builder::QueryBuilder, +}; use self::{ anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder, diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 2600999e1c..32ba35cd6d 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -4,30 +4,11 @@ use http::{HeaderMap, Method}; use serde_json::value::RawValue; use windmill_common::error::{Error, Result}; -use crate::ai_providers::{AIPlatform, AIProvider}; +use crate::ai_providers::AIProvider; +use crate::credentials::ProviderCredentials; use crate::utils::AI_HTTP_HEADERS; -/// Resolved provider credentials shared by API proxy and worker execution. -/// -/// Raw API resources and worker agent payloads convert into this shape at their -/// execution boundaries. Request-specific state such as the selected model stays -/// outside this type. -#[derive(Clone, Debug)] -pub struct ProviderCredentials { - pub provider: AIProvider, - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - pub region: Option, - pub aws_access_key_id: Option, - pub aws_secret_access_key: Option, - pub aws_session_token: Option, - pub platform: AIPlatform, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} +pub mod fim; /// Inputs needed to transform an OpenAI-compatible proxy request for a provider. pub struct ProxyBuildArgs<'a> { @@ -167,6 +148,9 @@ pub(crate) fn add_user_to_body(body: &[u8], user: &str) -> Result> { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + + use crate::ai_providers::AIPlatform; fn credentials(provider: AIProvider, base_url: &str) -> ProviderCredentials { ProviderCredentials { diff --git a/backend/windmill-ai/src/proxy/fim.rs b/backend/windmill-ai/src/proxy/fim.rs new file mode 100644 index 0000000000..2d14fd23ce --- /dev/null +++ b/backend/windmill-ai/src/proxy/fim.rs @@ -0,0 +1,120 @@ +use bytes::Bytes; +use serde::Deserialize; +use serde_json::json; +use windmill_common::error::{Error, Result}; + +use crate::ai_providers::AIProvider; + +#[derive(Debug, Eq, PartialEq)] +pub struct FimProxyTransform { + pub body: Bytes, + pub path: String, +} + +#[derive(Deserialize)] +struct FimRequest { + model: String, + prompt: String, + suffix: Option, + temperature: Option, + max_tokens: Option, + stop: Option>, +} + +pub fn supports_native_fim(provider: &AIProvider) -> bool { + matches!(provider, AIProvider::Mistral) +} + +pub fn maybe_transform_fim_request( + provider: &AIProvider, + path: &str, + body: &[u8], +) -> Result> { + if path.contains("fim/completions") && !supports_native_fim(provider) { + transform_fim_to_chat_completions(body).map(Some) + } else { + Ok(None) + } +} + +fn transform_fim_to_chat_completions(body: &[u8]) -> Result { + let fim_req: FimRequest = serde_json::from_slice(body) + .map_err(|e| Error::BadRequest(format!("Failed to parse FIM request: {}", e)))?; + + let suffix = fim_req.suffix.unwrap_or_default(); + + let system_prompt = "You are a code completion assistant. Complete the code at the position between the given prefix and suffix. Output ONLY the code that goes at the cursor - no explanations, no markdown, no repeating the prefix or suffix."; + + let user_content = format!( + "\n{}\n\n\n{}", + fim_req.prompt, suffix + ); + + let chat_req = json!({ + "model": fim_req.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content} + ], + "temperature": fim_req.temperature.unwrap_or(0.0), + "max_tokens": fim_req.max_tokens.unwrap_or(256), + "stop": fim_req.stop + }); + + let body = serde_json::to_vec(&chat_req) + .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; + + Ok(FimProxyTransform { body: Bytes::from(body), path: "chat/completions".to_string() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mistral_keeps_native_fim_request() { + let transformed = + maybe_transform_fim_request(&AIProvider::Mistral, "fim/completions", br#"{}"#).unwrap(); + + assert!(transformed.is_none()); + assert!(supports_native_fim(&AIProvider::Mistral)); + } + + #[test] + fn openai_fim_request_is_transformed_to_chat_completion() { + let transformed = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + br#"{ + "model": "gpt-4.1", + "prompt": "fn main() {", + "suffix": "}", + "stop": ["\n\n"] + }"#, + ) + .unwrap() + .expect("OpenAI FIM should be transformed"); + + assert_eq!(transformed.path, "chat/completions"); + + let body: serde_json::Value = serde_json::from_slice(&transformed.body).unwrap(); + assert_eq!(body["model"], "gpt-4.1"); + assert_eq!(body["temperature"], 0.0); + assert_eq!(body["max_tokens"], 256); + assert_eq!(body["stop"], serde_json::json!(["\n\n"])); + assert_eq!(body["messages"][1]["role"], "user"); + assert_eq!( + body["messages"][1]["content"], + "\nfn main() {\n\n\n}" + ); + } + + #[test] + fn invalid_fim_body_is_bad_request() { + let err = + maybe_transform_fim_request(&AIProvider::OpenAI, "fim/completions", br#"{"model": 1}"#) + .unwrap_err(); + + assert!(matches!(err, Error::BadRequest(_))); + } +} diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 1d18e2411c..56a796e41d 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -18,7 +18,7 @@ pub struct McpToolSource { use crate::{ ai_google::sanitize_schema_for_google, ai_providers::{empty_string_as_none, AIProvider}, - proxy::ProviderCredentials, + credentials::ProviderCredentials, }; use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule}; use windmill_parser::Typ; diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 6956192f04..81f601e53f 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -11,13 +11,14 @@ use http::{HeaderMap, Method}; use quick_cache::sync::Cache; use reqwest::{Client, RequestBuilder}; use serde::{Deserialize, Serialize}; -use serde_json::{json, value::RawValue}; +use serde_json::value::RawValue; use std::collections::HashMap; use std::time::Duration; use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; +use windmill_ai::credentials::ProviderCredentials; #[cfg(feature = "bedrock")] use windmill_ai::providers::bedrock::{ handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody, @@ -30,10 +31,9 @@ use windmill_ai::providers::{ }, }; use windmill_ai::proxy::{ - proxy_execution_mode, supports_query_builder_proxy, ProviderCredentials, ProxyBuildArgs, - ProxyExecutionMode, ProxyRequest, + fim::maybe_transform_fim_request, proxy_execution_mode, ProxyBuildArgs, ProxyExecutionMode, + ProxyRequest, }; -use windmill_ai::utils::AI_HTTP_HEADERS; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::db::UserDB; use windmill_common::error::{to_anyhow, Error, Result}; @@ -369,53 +369,6 @@ impl AIConfig { } } -// FIM (Fill-in-the-Middle) simulation for providers that don't support native FIM -#[derive(Deserialize, Debug)] -struct FimRequest { - model: String, - prompt: String, // code before cursor - suffix: Option, // code after cursor - temperature: Option, - max_tokens: Option, - stop: Option>, -} - -/// Checks if the AI provider supports native FIM (Fill-in-the-Middle) endpoint -fn supports_native_fim(provider: &AIProvider) -> bool { - matches!(provider, AIProvider::Mistral) -} - -/// Transforms a FIM request to chat/completions format for providers that don't support native FIM. -fn transform_fim_to_chat_completions(body: &Bytes) -> Result<(Bytes, String)> { - let fim_req: FimRequest = serde_json::from_slice(body) - .map_err(|e| Error::internal_err(format!("Failed to parse FIM request: {}", e)))?; - - let suffix = fim_req.suffix.unwrap_or_default(); - - let system_prompt = "You are a code completion assistant. Complete the code at the position between the given prefix and suffix. Output ONLY the code that goes at the cursor - no explanations, no markdown, no repeating the prefix or suffix."; - - let user_content = format!( - "\n{}\n\n\n{}", - fim_req.prompt, suffix - ); - - let chat_req = json!({ - "model": fim_req.model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content} - ], - "temperature": fim_req.temperature.unwrap_or(0.0), - "max_tokens": fim_req.max_tokens.unwrap_or(256), - "stop": fim_req.stop - }); - - let chat_body = serde_json::to_vec(&chat_req) - .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; - - Ok((Bytes::from(chat_body), "chat/completions".to_string())) -} - pub fn global_service() -> Router { Router::new().route("/proxy/{*ai}", post(global_proxy).get(global_proxy)) } @@ -455,6 +408,24 @@ fn proxy_request_to_request_builder(proxy_request: ProxyRequest) -> RequestBuild request.body(proxy_request.body) } +async fn audit_global_ai_request(db: &DB, authed: &ApiAuthed) -> Result<()> { + let mut tx = db.begin().await?; + + audit_log( + &mut *tx, + authed, + "ai.global_request", + ActionKind::Execute, + "global", + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(()) +} + fn google_ai_proxy_response_to_body( response: GoogleAIProxyResponse, ) -> (http::StatusCode, HeaderMap, axum::body::Body) { @@ -530,63 +501,78 @@ async fn global_proxy( return Err(Error::BadRequest("API key is required".to_string())); }; - let base_url = provider.get_base_url(None, &db).await?; + let proxy_mode = proxy_execution_mode(&provider); - let request = if supports_query_builder_proxy(&provider) { - let credentials = ProviderCredentials { - provider: provider.clone(), - base_url, - api_key: Some(api_key.clone()), - access_token: None, - organization_id: None, - user: None, - region: None, - aws_access_key_id: None, - aws_secret_access_key: None, - aws_session_token: None, - platform: AIPlatform::Standard, - enable_1m_context: false, - custom_headers: HashMap::new(), - }; - let query_builder = create_query_builder(&credentials); - let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { + return Err(Error::BadRequest( + "AWS Bedrock global proxy is not supported; use a workspace AI resource with a region" + .to_string(), + )); + } + + let base_url = provider.get_base_url(None, &db).await?; + let credentials = ProviderCredentials { + provider: provider.clone(), + base_url, + api_key: Some(api_key.clone()), + access_token: None, + organization_id: None, + user: None, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + platform: AIPlatform::Standard, + enable_1m_context: false, + custom_headers: HashMap::new(), + }; + + if matches!(proxy_mode, ProxyExecutionMode::NativeGoogleAi) { + let proxy_args = ProxyBuildArgs { method: &method, path: &ai_path, headers: &headers, body: &body, credentials: &credentials, - })?; - proxy_request_to_request_builder(proxy_request) - } else { - let url = format!("{}/{}", base_url, ai_path); - let mut request = HTTP_CLIENT - .request(method, url) - .header("content-type", "application/json") - .header("Authorization", format!("Bearer {}", &api_key)); + }; - // Apply custom headers from AI_HTTP_HEADERS environment variable - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - request = request.header(header_name.as_str(), header_value.as_str()); + audit_global_ai_request(&db, &authed).await?; + + let response = match ai_path.as_str() { + "chat/completions" => handle_google_ai_chat_proxy(&HTTP_CLIENT, &proxy_args).await, + "models" => handle_google_ai_models_proxy(&HTTP_CLIENT, &proxy_args).await, + _ => Err(Error::BadRequest(format!( + "Unsupported Google AI path: {}", + ai_path + ))), + }?; + + return Ok(google_ai_proxy_response_to_body(response)); + } + + let request = match proxy_mode { + ProxyExecutionMode::HttpForward => { + let query_builder = create_query_builder(&credentials); + let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + })?; + proxy_request_to_request_builder(proxy_request) + } + ProxyExecutionMode::NativeGoogleAi | ProxyExecutionMode::NativeAwsBedrock => { + return Err(Error::BadRequest(format!( + "Unsupported global proxy mode for provider {:?}", + provider + ))) } - - request.body(body) }; let response = request.send().await.map_err(to_anyhow)?; - let mut tx = db.begin().await?; - - audit_log( - &mut *tx, - &authed, - "ai.global_request", - ActionKind::Execute, - "global", - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; + audit_global_ai_request(&db, &authed).await?; if response.error_for_status_ref().is_err() { let err_msg = response.text().await.unwrap_or("".to_string()); @@ -772,17 +758,13 @@ async fn proxy( } }; - // Check if this is a FIM request to a provider that doesn't support native FIM endpoint - // For such providers, transform to use FIM sentinel tokens with the chat/completions endpoint - let is_fim_request = ai_path.contains("fim/completions"); - if is_fim_request && !supports_native_fim(&provider) { + if let Some(fim_transform) = maybe_transform_fim_request(&provider, &ai_path, &body)? { tracing::debug!( "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", provider ); - let (chat_body, chat_path) = transform_fim_to_chat_completions(&body)?; - body = chat_body; - ai_path = chat_path; + body = fim_transform.body; + ai_path = fim_transform.path; } let proxy_mode = proxy_execution_mode(&provider); diff --git a/backend/windmill-worker/src/ai/mod.rs b/backend/windmill-worker/src/ai/mod.rs index 24e877ab13..ad0986bbea 100644 --- a/backend/windmill-worker/src/ai/mod.rs +++ b/backend/windmill-worker/src/ai/mod.rs @@ -1,6 +1,6 @@ // AI executor module structure // This module will contain all AI-related execution logic -pub mod query_builder; +pub mod stream_event_processor; pub mod tools; pub mod utils; diff --git a/backend/windmill-worker/src/ai/query_builder.rs b/backend/windmill-worker/src/ai/stream_event_processor.rs similarity index 100% rename from backend/windmill-worker/src/ai/query_builder.rs rename to backend/windmill-worker/src/ai/stream_event_processor.rs diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index fcc9cdf3c9..11100d4888 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -1,4 +1,4 @@ -use crate::ai::query_builder::StreamEventProcessor; +use crate::ai::stream_event_processor::StreamEventProcessor; use crate::ai::utils::{ add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow, is_completed_input_transform, update_flow_status_module_with_actions, diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 49c24eac2d..b2478e3be1 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -45,7 +45,7 @@ use windmill_common::{ use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob}; use crate::{ - ai::query_builder::StreamEventProcessor, + ai::stream_event_processor::StreamEventProcessor, common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier}, handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome}, }; diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md deleted file mode 100644 index 6dcd2d7605..0000000000 --- a/docs/windmill-ai-refactor-plan.md +++ /dev/null @@ -1,441 +0,0 @@ -# Refactor Plan: `windmill-ai` Crate - -## Context - -AI provider logic is currently split across three crates with duplicate code: - -- **windmill-common** — base types (`ai_types`, `ai_providers`, `ai_google`, `ai_bedrock`, `ai_cache`) -- **windmill-api** — chat proxy routes (`ai.rs`), audit logging, caching, and DB-backed credential resolution into `ProviderCredentials` -- **windmill-worker** — agent execution (`ai/` module) with `QueryBuilder` trait, SSE parsers, provider implementations - -The goal: a single `windmill-ai` crate with all AI provider logic. Worker agent execution uses `QueryBuilder`; the API proxy uses `QueryBuilder::build_proxy_request` for HTTP-forwarding providers and native proxy handlers for providers that need response conversion or SDK execution. - -## Dependency Direction - -``` -windmill-ai → windmill-common (for DB, Error, AgentAction, AuthedClient, etc.) - → windmill-types (for S3Object) - → windmill-parser (for Typ, used in OpenAPISchema) - -windmill-api → windmill-ai -windmill-worker → windmill-ai -``` - -windmill-common does **NOT** re-export from windmill-ai (would be circular). All consumers update imports. - -## Reviewer Note: Keep API Proxy Unification Split - -The crate boundary, shared utilities, SSE parsers, image handling, worker provider implementations, provider-specific API proxy transformations, and resolved runtime credential shape are now in `windmill-ai`. Raw API resources and worker agent provider payloads remain separate input/deserialization shapes and convert into `ProviderCredentials` at execution boundaries. - -Do not jump directly from the current state to full proxy and credential unification in one PR. The API proxy combines request transformation, endpoint selection, auth headers, custom headers, OAuth user injection, Azure URL handling, Anthropic Vertex handling, Bedrock SDK calls, and SSE keepalive behavior. Split the work by risk: -- Introduce shared proxy request and credential types first. -- Move the OpenAI-compatible proxy path into `windmill-ai` next, while keeping provider-native behavior unchanged. -- Move Anthropic/Vertex, Google AI, and Bedrock in separate follow-up PRs. -- Unify credential resolution only after all proxy request builders use the shared shape. - -Avoid adding modules whose only purpose is to re-export moved code. Direct imports from `windmill_ai` make ownership and dependency direction clearer at each call site. - -Also do not make `build_proxy_request(raw_body, path)` too narrow. The proxy path needs method, incoming headers, resolved credentials, base URL/platform, organization/user fields, custom headers, and Bedrock/Azure/Vertex-specific context. Introduce a structured `ProxyBuildArgs`/`ProviderCredentials` shape before deleting `AIRequestConfig::prepare_request`, `google.rs`, or `bedrock.rs`. - -## Completed Phase: Proxy Contract + OpenAI-Compatible Proxy ✅ - -Goal: introduce the shared API proxy contract in `windmill-ai` and move the OpenAI-compatible proxy request builder there without changing provider behavior. - -Suggested PR title: `refactor(ai): move openai-compatible proxy building to windmill-ai`. - -Scope: -- Add `windmill-ai/src/proxy.rs` and export it from `lib.rs`. -- Define `ProviderCredentials`, `ProxyBuildArgs`, and `ProxyRequest`. -- Include all context known to be needed by the current API proxy path: method, path, incoming headers, body, provider, base URL, API key, OAuth access token, organization/user fields, platform, 1M context flag, custom headers, region, and AWS credentials. -- Add a conversion from API-side `AIRequestConfig` to `ProviderCredentials`. -- Add `QueryBuilder::build_proxy_request` with a default unsupported-provider implementation. -- Implement `build_proxy_request` for OpenAI-compatible providers (`OpenAI`, `AzureOpenAI`, `Mistral`, `DeepSeek`, `Groq`, `OpenRouter`, `TogetherAI`, `CustomAI`). -- Route workspace and global API proxy requests for OpenAI-compatible providers through `windmill-ai`. -- Keep FIM transformation in `windmill-api` before calling the proxy builder. -- Keep `AIRequestConfig::prepare_request` for Anthropic/Vertex and remaining fallback paths. - -Out of scope: -- Do not move Anthropic/Vertex proxy behavior yet. -- Do not move Google AI or Bedrock proxy behavior yet. -- Do not change credential resolution, audit logging, cache behavior, SSE keepalive behavior, or Bedrock/Google special cases. -- Do not remove `windmill-api/src/google.rs`, `windmill-api/src/bedrock.rs`, or `AIRequestConfig::prepare_request`. - -Validation: -- `cargo test -p windmill-ai proxy` -- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` -- `cargo check -p windmill-ai -p windmill-api` -- `cargo check -p windmill-ai -p windmill-api --features bedrock` - -Follow-up status: Anthropic/Vertex proxy handling has since moved into -`windmill-ai`, and the dead `AIRequestConfig::prepare_request` fallback has -been removed. - -## Completed Phase: Proxy Execution Mode + Google AI Proxy Migration ✅ - -Goal: introduce a shared provider execution classifier before moving Google AI -and Bedrock. `ProxyRequest` is a good contract for HTTP-forwarding providers -such as OpenAI-compatible providers and Anthropic, but Google AI also converts -responses back to OpenAI shape and Bedrock uses SDK execution. Model that split -explicitly before moving those providers, then move the Google AI proxy -transformation into `windmill-ai` as the first native-provider migration. - -Suggested PR title: `refactor(ai): add provider proxy execution mode`. - -Scope: -- Add `ProxyExecutionMode` in `windmill-ai::proxy`. -- Classify providers as HTTP-forwarding, native Google AI, or native Bedrock. -- Make `supports_query_builder_proxy` derive from the shared execution mode. -- Use the shared execution mode in `windmill-api/src/ai.rs` for workspace proxy routing. -- Move Google AI workspace proxy request conversion, streaming/non-streaming response conversion, and model-list normalization into `windmill-ai::providers::google_ai`. -- Share Google AI `GeminiTextRequest` and generation-config construction between worker agent requests and API proxy requests. -- Delete the API-local `windmill-api/src/google.rs` module. -- Keep global proxy behavior, Bedrock native handling, credential resolution, audit logging, caching, and SSE keepalive behavior unchanged. - -Out of scope: -- Do not move `windmill-api/src/bedrock.rs`. -- Do not unify `AIRequestConfig` and `ProviderWithResource`. - -Validation: -- `cargo test -p windmill-ai google_ai` -- `cargo test -p windmill-ai proxy` -- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` -- `cargo test -p windmill-ai anthropic` - -Follow-up status: Bedrock native proxy handling has since moved into -`windmill-ai`, and the API-local `windmill-api/src/bedrock.rs` module has been -removed. - -## Completed Phase: Bedrock Native Proxy Migration ✅ - -Goal: move the remaining native-provider API proxy execution out of -`windmill-api` and into `windmill-ai`, while leaving API-owned routing, -credential resolution, auditing, cache behavior, and Axum response conversion in -`windmill-api`. - -Suggested PR title: `refactor(ai): move bedrock proxy handling to windmill-ai`. - -Scope: -- Move Bedrock control-plane proxy calls (`foundation-models`, - `inference-profiles`) into `windmill-ai::providers::bedrock`. -- Move Bedrock chat proxy OpenAI request parsing, Converse request execution, - streaming SSE conversion, non-streaming OpenAI-shaped response conversion, and - auth selection into `windmill-ai::providers::bedrock`. -- Add an Axum-free `BedrockProxyResponse` shape in `windmill-ai`; the API route - converts it into an Axum body. -- Move the optional `aws-sdk-bedrock` dependency from `windmill-api` to - `windmill-ai`. -- Delete the API-local `windmill-api/src/bedrock.rs` module. - -Out of scope: -- Do not unify `AIRequestConfig` and `ProviderWithResource`. -- Do not change Bedrock credential resolution, audit logging, request caching, - or non-Bedrock proxy behavior. - -Validation: -- `cargo test -p windmill-ai bedrock --features bedrock` -- `cargo check -p windmill-ai -p windmill-api` -- `cargo check -p windmill-ai -p windmill-api --features bedrock` - -## Known Follow-Ups - -These are not blockers for the current migration PR because they either preserve -existing behavior or need a separate product decision, but they should stay -visible for later hardening work. - -- **Google AI/Gemini native proxy custom headers**: the native Google AI proxy - path intentionally does not apply `AI_HTTP_HEADERS` or resource-level custom - headers today. Decide whether and how env/resource custom-header injection - should apply to Google AI once the proxy behavior is unified further. -- **Bedrock SSE tool-call indexing**: Bedrock streaming currently increments - the OpenAI tool-call index on every Bedrock `ContentBlockStop`, including text - content blocks. This behavior existed before the move from `windmill-api` to - `windmill-ai`, but a later cleanup should advance the index only when the - stopped block was a tool-use block. -- **Bedrock SSE keepalives**: Bedrock native SSE streams are still returned - directly without the API proxy keepalive injection used by other SSE paths. - This also preserves the pre-move behavior. A later cleanup can generalize the - keepalive wrapper so it works for both `reqwest::Error` streams and Bedrock's - SDK-backed `std::io::Error` streams. - -## Completed Phase: Credential Unification Phase 1 ✅ - -Goal: make `ProviderCredentials` the shared resolved runtime credential shape -without overloading it with raw resource input or model-selection state. - -`AIRequestConfig` and `ProviderWithResource` are not equivalent concepts: -`AIRequestConfig` is API-side resolved state after DB, variable, OAuth, and -resource handling, while `ProviderWithResource` is worker-side raw agent input -that also carries the selected model. Keep raw/deserialization types separate and -convert them into `ProviderCredentials` at execution boundaries. - -Suggested PR title: `refactor(ai): use provider credentials for worker builders`. - -Scope: -- Add a worker-side conversion from `ProviderWithResource` to - `ProviderCredentials`. -- Keep `model` outside `ProviderCredentials`; it remains agent request data. -- Keep `ProviderWithResource` as the backward-compatible deserialization type for - existing agent payloads. -- Use `ProviderCredentials` for worker query-builder creation. -- Collapse `create_query_builder` and `create_proxy_query_builder` into one - `create_query_builder(&ProviderCredentials)` factory. - -Out of scope: -- Do not remove API-local `AIRequestConfig` yet. -- Do not change API request-cache behavior. -- Do not change worker agent payload shape or serialized field names. - -Validation: -- `cargo check -p windmill-ai -p windmill-api -p windmill-worker` -- `cargo check -p windmill-ai -p windmill-api -p windmill-worker --features bedrock` - -## Completed Phase: Credential Unification Phase 2 ✅ - -Goal: remove the API-local resolved credential wrapper after worker execution -already uses the shared shape. - -Suggested PR title: `refactor(ai): resolve api proxy credentials directly`. - -Scope: -- Change API credential resolution to return `ProviderCredentials` directly. -- Replace `ExpiringAIRequestConfig` with an expiring `ProviderCredentials` - cache entry. -- Remove `AIRequestConfig::into_provider_credentials`. -- Delete `AIRequestConfig` entirely if no API-only behavior remains. - -Out of scope: -- Do not merge raw worker resource input into `ProviderCredentials`. -- Do not put model selection into `ProviderCredentials`. - -Validation: -- `cargo check -p windmill-ai -p windmill-api -p windmill-worker` -- `cargo check -p windmill-ai -p windmill-api -p windmill-worker --features bedrock` -- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` - -## Step-by-Step Plan - -Each step produces a compiling, working backend. - ---- - -### Step 1: Create `windmill-ai` crate, move base types from windmill-common ✅ - -Create `backend/windmill-ai/Cargo.toml` and `backend/windmill-ai/src/lib.rs`. - -Move from `windmill-common/src/` to `windmill-ai/src/`: -- `ai_types.rs` — OpenAI-compatible message types -- `ai_providers.rs` — `AIProvider` enum, `AIPlatform`, base URLs, `ProviderConfig` -- `ai_google.rs` — Gemini types and OpenAI↔Gemini conversion -- `ai_bedrock.rs` — Bedrock SDK wrapper (feature-gated on `bedrock`) -- `ai_cache.rs` — instance AI config revision tracking - -Update all imports (`windmill_common::ai_*` → `windmill_ai::ai_*`). - ---- - -### Step 2: Move worker AI types to windmill-ai ✅ - -Move from `windmill-worker/src/ai/types.rs` to `windmill-ai/src/types.rs`: -- `ProviderWithResource`, `ProviderResource` — credential types -- `TokenUsage` — token usage tracking -- `OutputType`, `SchemaType`, `AdditionalProperties` — output configuration -- `OpenAPISchema` — tool parameter schema (depends on `windmill-parser::Typ`) -- `Tool`, `Message`, `ResponseFormat`, `JsonSchemaFormat` — agent types -- `StreamingEvent` — SSE event enum -- `AIAgentArgs`, `AIAgentArgsRaw`, `AIAgentResult` — agent job args -- `Memory` — agent memory enum -- `S3ObjectWithType` — S3 image type -- `McpToolSource` stub (with same `#[cfg(feature = "mcp")]` pattern) - -Worker `ai/types.rs` becomes a re-export: `pub use windmill_ai::types::*`. - ---- - -### Step 3: Move QueryBuilder trait, ParsedResponse, and StreamEventSink abstraction to windmill-ai ✅ - -Move from `windmill-worker/src/ai/query_builder.rs` to `windmill-ai/src/query_builder.rs`: -- `BuildRequestArgs` struct -- `ParsedResponse` enum -- `QueryBuilder` trait (with all existing methods) - -New `StreamEventSink` trait in windmill-ai: -```rust -#[async_trait] -pub trait StreamEventSink: Send + Sync { - async fn send(&self, event: StreamingEvent, events_str: &mut String) -> Result<(), Error>; -} -``` - -`StreamEventSink` abstracts the worker's `StreamEventProcessor` so windmill-ai doesn't depend on windmill-queue or the worker's job logger. The worker's `StreamEventProcessor` implements `StreamEventSink`. All provider `parse_streaming_response` methods and SSE parsers accept `Box`. - ---- - -### Step 4: Move SSE parsers to windmill-ai ✅ - -Move from `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`: -- `SSEParser` trait -- `OpenAISSEParser`, `AnthropicSSEParser`, `GeminiSSEParser`, `OpenAIResponsesSSEParser` -- All associated types (delta types, usage types, etc.) - ---- - -### Step 5: Move provider implementations to windmill-ai ✅ - -Move from `windmill-worker/src/ai/providers/` to `windmill-ai/src/providers/`: -- `anthropic.rs` — `AnthropicQueryBuilder` -- `openai.rs` — `OpenAIQueryBuilder` -- `google_ai.rs` — `GoogleAIQueryBuilder` -- `bedrock.rs` — `BedrockQueryBuilder` (feature-gated) -- `other.rs` — `OtherQueryBuilder` (Mistral, DeepSeek, Groq, TogetherAI, CustomAI) -- `openrouter.rs` — `OpenRouterQueryBuilder` -- `mod.rs` with `create_query_builder` factory - -Move utility functions providers depend on: -- `should_use_structured_output_tool` (from `utils.rs`) -- `extract_text_content` (from `utils.rs`) - ---- - -### Step 6: Move image_handler to windmill-ai ✅ - -Move from `windmill-worker/src/ai/image_handler.rs` to `windmill-ai/src/image_handler.rs`: -- `download_and_encode_s3_image` — no signature change needed -- `prepare_messages_for_api` — no signature change needed -- `upload_image_to_s3` — **refactor**: `(base64_image, workspace_id, job_id, client)` instead of `(base64_image, &MiniPulledJob, client)` to remove windmill-queue dependency - ---- - -### Step 7: Move shared utilities to windmill-ai ✅ - -Move `AI_HTTP_HEADERS` lazy_static (currently duplicated in `windmill-api/src/ai.rs` and `windmill-worker/src/ai_executor.rs`) to `windmill_ai::utils`. Both consumers import from windmill-ai. - ---- - -### Step 8: Add API proxy execution support to windmill-ai ✅ - -This is the key proxy unification step. HTTP-forwarding providers use -`QueryBuilder::build_proxy_request`: - -```rust -/// Build a request from a raw OpenAI-format proxy request. -/// Used by the API chat proxy. Handles format conversion for non-OpenAI providers. -fn build_proxy_request( - &self, - args: &ProxyBuildArgs<'_>, -) -> Result; -``` - -Where `ProxyBuildArgs` carries the API proxy context that provider implementations need: -```rust -pub struct ProxyBuildArgs<'a> { - pub method: &'a http::Method, - pub path: &'a str, - pub headers: &'a http::HeaderMap, - pub body: &'a [u8], - pub credentials: &'a ProviderCredentials, -} -``` - -And `ProxyRequest` contains the transformed request: -```rust -pub struct ProxyRequest { - pub method: http::Method, - pub url: String, - pub headers: Vec<(String, String)>, - pub body: Vec, -} -``` - -**Provider implementations:** -- **OpenAI-compatible** (OpenAI, Mistral, DeepSeek, Groq, TogetherAI, CustomAI, OpenRouter): Minimal transformation — pass body through, build URL and auth headers. -- **Anthropic**: Handle standard vs Vertex AI. For Vertex: transform body (extract model, add anthropic_version). For standard: pass through with appropriate headers. -- **Google AI**: Native execution mode converts OpenAI format → Gemini format and Gemini responses → OpenAI shape. Replaces `windmill-api/src/google.rs`. -- **Bedrock**: Native execution mode converts OpenAI format → Bedrock SDK calls and SDK responses → OpenAI shape. Replaces `windmill-api/src/bedrock.rs`. - -**Refactor API proxy** (`windmill-api/src/ai.rs`): -1. Parse provider from headers, resolve credentials → `ProviderCredentials` -2. Create `QueryBuilder` via `create_query_builder` -3. Dispatch by `ProxyExecutionMode`: - - HTTP-forwarding providers call `query_builder.build_proxy_request(&proxy_args)` → `ProxyRequest` - - Google AI and Bedrock call native handlers in `windmill-ai` -4. Convert the provider response to the API response body - -**Remove** from windmill-api: -- `AIRequestConfig::prepare_request` — replaced by `QueryBuilder::build_proxy_request` -- `google.rs` — replaced by `windmill_ai::providers::google_ai` native proxy handlers -- `bedrock.rs` — replaced by `windmill_ai::providers::bedrock` native proxy handlers -- `transform_anthropic_for_vertex` — moved to `AnthropicQueryBuilder` -- `supports_native_fim`, `transform_fim_to_chat_completions` — moved to windmill-ai - -**Keep** in API: -- credential resolution from DB, workspace settings, instance settings, variables, and OAuth into `ProviderCredentials` -- HTTP routes, audit logging, request caching -- `inject_keepalives`, `is_sse_response` helpers -- `AIConfig`, `ExpiringProviderCredentials` caching types - ---- - -### Step 9: Unify credential resolution - -Make `ProviderCredentials` the single resolved runtime credential shape in -windmill-ai, while keeping raw API and worker input/deserialization types at -their boundaries. - -The API's `resolve_provider_credentials` resolves credentials from DB, workspace -or instance settings, variables, and OAuth. The worker's `ProviderWithResource` -gets raw credentials from the flow module definition and also carries the -selected model. Convert both paths into `ProviderCredentials`; do not make -`ProviderCredentials` carry raw resource state or the model. - -Extend `windmill_ai::proxy::ProviderCredentials` as needed so both can produce it: -```rust -pub struct ProviderCredentials { - pub provider: AIProvider, - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - pub platform: AIPlatform, - pub region: Option, - pub aws_access_key_id: Option, - pub aws_secret_access_key: Option, - pub aws_session_token: Option, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} -``` - -The `create_query_builder` factory takes `&ProviderCredentials` instead of `&ProviderWithResource`. - ---- - -## Final Crate Structure - -``` -windmill-ai/src/ -├── lib.rs # module exports -├── ai_types.rs # OpenAI-compatible message types -├── ai_providers.rs # AIProvider enum, base URLs, config -├── ai_google.rs # Gemini types and conversions -├── ai_bedrock.rs # Bedrock SDK wrapper (feature: bedrock) -├── ai_cache.rs # Instance AI config revision -├── types.rs # TokenUsage, Tool, OpenAPISchema, etc. -├── proxy.rs # ProviderCredentials, ProxyBuildArgs, ProxyRequest -├── query_builder.rs # QueryBuilder trait, BuildRequestArgs, ParsedResponse, StreamEventSink -├── sse.rs # SSE parsers (OpenAI, Anthropic, Gemini, Responses) -├── image_handler.rs # S3 image upload/download -├── utils.rs # extract_text_content, should_use_structured_output_tool -└── providers/ - ├── mod.rs # create_query_builder factory - ├── anthropic.rs # build_request + build_proxy_request - ├── openai.rs # build_request + build_proxy_request - ├── google_ai.rs # build_request + native proxy handlers - ├── bedrock.rs # build_request + native proxy handlers (feature: bedrock) - ├── other.rs # build_request + build_proxy_request - └── openrouter.rs # build_request + build_proxy_request -``` - -**windmill-worker** keeps: `ai_executor.rs`, `ai/tools.rs`, `ai/utils.rs` (flow/conversation/MCP logic), `StreamEventProcessor` (impl of `StreamEventSink`). - -**windmill-api** keeps: HTTP routes (`ai.rs` proxy endpoints), audit logging, caching, credential resolution from DB. `google.rs` and `bedrock.rs` deleted. From 2fdc51e62985fc755884436130bdd58e294247c8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 18:13:38 +0200 Subject: [PATCH 02/11] fix(git-sync): publish fork branch on only_create_branch from the CLI (#9366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] fix(git-sync): publish fork branch on only_create_branch from the CLI Fixes WIN-1997. Forking a git-sync-configured workspace must push a `wm-fork//` branch to the repo, but the integration test `test_workspace_fork_creates_branch` failed: the fork callback job succeeded yet no branch appeared. Root cause: the fork-branch callback runs the sync script with `only_create_branch: true` and no items. The hub sync script delegates branch checkout to `wmill sync git-deploy --only-create-branch` and runs its own in-process commit+push ONLY for the `!only_create_branch` path (`if (!only_create_branch) git_push(...)`). #9284 had moved commit+push out of the CLI to the caller for the GPG-cache-warmth invariant (WIN-1974) — but it also dropped the CLI's push for the branch-only case. A branch-only publish has no commit, so no signing is involved and the GPG concern does not apply; with neither the CLI nor the hub script pushing, the empty fork branch was never published. Restore the CLI push for the `only_create_branch` path (a bare `git push --porcelain` of the checked-out branch ref). Adds a deterministic CLI regression test that runs `git-deploy --only-create-branch` for a fork workspace and asserts the branch reaches the remote with no caller-side push. EE companion: format the fork-branch commit message with Display instead of Debug (no more `Some("...")` leak). Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private. Previous ee-repo-ref: 8b02336fcebdfae4b9d2795cbb74fa7046530bcb New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- cli/src/commands/sync/sync.ts | 16 ++++++- cli/test/gitsync_promotion.test.ts | 71 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 80a746c1c0..f457a34fb7 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -55c19293232be379a3044eb78f677b545882ffd6 \ No newline at end of file +a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 22cdd5900b..2632f6c80c 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -2504,8 +2504,20 @@ export async function pull( } if (opts.onlyCreateBranch) { - // Branch is checked out locally; the caller pushes it. Symmetric with - // the non-onlyCreateBranch path: CLI does branch + pull, never push. + // Branch-only publish: there is no commit here, so the GPG-cache-warmth + // invariant that motivated moving commit+push to the hub script (WIN-1974, + // #9284) does not apply — a bare `git push` of the (empty) branch ref needs + // no signing. The hub script only runs its in-process commit+push for the + // non-onlyCreateBranch path (`if (!only_create_branch) git_push(...)`), so + // the CLI MUST publish the fork branch here or it is never pushed at all. + gitSyncDeployPush({ + items: deployItems, + authorName: process.env["WM_USERNAME"] || "windmill", + authorEmail: process.env["WM_EMAIL"] || "windmill@windmill.dev", + committerName: opts.gitCommitterName, + committerEmail: opts.gitCommitterEmail, + onlyCreateBranch: true, + }); return; } } diff --git a/cli/test/gitsync_promotion.test.ts b/cli/test/gitsync_promotion.test.ts index 5b73e8f8e1..709ae11fc2 100644 --- a/cli/test/gitsync_promotion.test.ts +++ b/cli/test/gitsync_promotion.test.ts @@ -200,3 +200,74 @@ test.skipIf(shouldSkipOnCI())( }); }, ); + +/** + * Regression test for WIN-1997: forking a workspace with git sync configured + * must publish a `wm-fork//` branch to the remote. + * + * The fork-branch callback runs the sync script with `only_create_branch: + * true` and no items. The hub script delegates branch checkout + push of that + * empty ref to `wmill sync git-deploy --only-create-branch` — its own + * in-process commit+push runs ONLY for the `!only_create_branch` path. So if + * the CLI doesn't push the freshly checked-out branch here, nothing does and + * the fork branch never reaches the remote (the symptom that broke the e2e + * test after #9284 moved commit+push to the caller). This guards that the CLI + * owns the push for the branch-only case. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync fork: only_create_branch publishes the wm-fork branch (CLI owns the push)", + async () => { + await withTestBackend(async (backend) => { + // Bare "remote" seeded with an initial `main` commit. + const bareDir = await mkdtemp(join(tmpdir(), "wmill_fork_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_fork_seed_")); + git(seedDir, "init", "--initial-branch=main"); + git(seedDir, "config", "user.email", "seed@windmill.dev"); + git(seedDir, "config", "user.name", "seed"); + await writeFile(join(seedDir, "README.md"), "# fork test\n"); + git(seedDir, "add", "-A"); + git(seedDir, "commit", "-m", "seed"); + git(seedDir, "remote", "add", "origin", `file://${bareDir}`); + git(seedDir, "push", "-u", "origin", "main"); + const seedMain = remoteHead(bareDir, "main"); + + // The CWD the hub script runs git-deploy in: a clone of the repo on main. + const work = await mkdtemp(join(tmpdir(), "wmill_fork_work_")); + git(work, "clone", `file://${bareDir}`, "."); + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\n", + ); + + // Branch creation happens BEFORE the fork workspace exists (step 1 of the + // fork flow), so we pass the fork workspace id straight through — whoami + // returns synthetic superadmin info for it. No items, only_create_branch. + const forkWs = "wm-fork-clitest"; + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/unused_on_branch_only_path", + "--git-deploy-items", + "[]", + "--only-create-branch", + ], + work, + { workspace: forkWs }, + ); + expect(res.code).toBe(0); + + // The regression: with NO caller-side commit/push, the fork branch must + // already be on the remote because the CLI pushed it. + expect(remoteBranches(bareDir)).toContain("refs/heads/wm-fork/main/clitest"); + // Base branch untouched — branch-only publish creates no commit. + expect(remoteHead(bareDir, "main")).toBe(seedMain); + + await rm(bareDir, { recursive: true, force: true }); + await rm(seedDir, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + }); + }, +); From 9a659b636d713ee8fdfbdad41c58bb3d7c79e0d9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 18:16:12 +0200 Subject: [PATCH 03/11] fix(frontend): prevent duplicate asset node ids crashing flow graph (#9367) --- .../graph/renderers/nodes/AssetNode.svelte | 86 +++++++++---------- .../graph/renderers/nodes/assetNode.test.ts | 51 +++++++++++ 2 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index 50e4f9c87a..0d5a9a14ea 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -63,7 +63,7 @@ type: 'asset' as const, parentId: node.id, data: { asset, displayedAccessType: 'r' }, - id: `${node.id}-asset-in-${asset.kind}-${asset.path}`, + id: `${node.id}-asset-in-${asset.kind}-${asset.path}-${i}`, width: inputAssetWidth, position: { x: @@ -100,7 +100,7 @@ type: 'asset' as const, parentId: node.id, data: { asset, displayedAccessType: 'w' }, - id: `${node.id}-asset-out-${asset.kind}-${asset.path}`, + id: `${node.id}-asset-out-${asset.kind}-${asset.path}-${i}`, width: outputAssetWidth, position: { x: @@ -136,7 +136,7 @@ allAssetNodes.push(...(inputAssetNodes ?? []), ...(outputAssetNodes ?? [])) // If there are more than 3 assets, we create an overflow node - if (overflowedInputAssets.length) + if (overflowedInputAssets.length) { allAssetNodes.push({ type: 'assetsOverflowed', data: { overflowedAssets: overflowedInputAssets, displayedAccessType: 'r' }, @@ -148,14 +148,15 @@ y: READ_ASSET_Y_OFFSET } } satisfies Node & AssetsOverflowedN) - allAssetEdges.push({ - id: `${node.id}-assets-overflowed-in-edge`, - source: `${node.id}-assets-overflowed-in`, - target: node.id, - type: 'empty', - data: { class: '!opacity-35 dark:!opacity-20' } - }) - if (overflowedOutputAssets.length) + allAssetEdges.push({ + id: `${node.id}-assets-overflowed-in-edge`, + source: `${node.id}-assets-overflowed-in`, + target: node.id, + type: 'empty', + data: { class: '!opacity-35 dark:!opacity-20' } + }) + } + if (overflowedOutputAssets.length) { allAssetNodes.push({ type: 'assetsOverflowed', data: { overflowedAssets: overflowedOutputAssets, displayedAccessType: 'w' }, @@ -167,13 +168,14 @@ y: WRITE_ASSET_Y_OFFSET } } satisfies Node & AssetsOverflowedN) - allAssetEdges.push({ - id: `${node.id}-assets-overflowed-out-edge`, - source: node.id, - target: `${node.id}-assets-overflowed-out`, - type: 'empty', - data: { class: '!opacity-35 dark:!opacity-25' } - }) + allAssetEdges.push({ + id: `${node.id}-assets-overflowed-out-edge`, + source: node.id, + target: `${node.id}-assets-overflowed-out`, + type: 'empty', + data: { class: '!opacity-35 dark:!opacity-25' } + }) + } } let ret: ReturnType = { @@ -274,8 +276,8 @@ {#snippet text()} - Could not find resource - {/snippet} + Could not find resource + {/snippet} {:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !$userStore?.operator}
@@ -291,29 +293,27 @@ {/if}
{#snippet text()} - - {#if usageCount !== undefined} - Used in {pluralize(usageCount, 'step')}
- {/if} - { - if (data.asset.kind === 'resource') - flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path) - }} - > - {data.asset.path} -
- - {formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })} - - - {/snippet} + {#if usageCount !== undefined} + Used in {pluralize(usageCount, 'step')}
+ {/if} + { + if (data.asset.kind === 'resource') + flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path) + }} + > + {data.asset.path} +
+ + {formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })} + + {/snippet} {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts b/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts new file mode 100644 index 0000000000..14fd5e24e3 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock heavy transitive imports pulled in by AssetNode.svelte's instance script +vi.mock('monaco-editor', () => ({})) +vi.mock('$lib/components/meltComponents', () => ({ Tooltip: {} })) +vi.mock('../../../ExploreAssetButton.svelte', () => ({ + default: {}, + assetCanBeExplored: () => false +})) +vi.mock('$lib/components/icons/AssetGenericIcon.svelte', () => ({ default: {} })) +vi.mock('$lib/components/assets/AssetColumnBadges.svelte', () => ({ default: {} })) +vi.mock('./NodeWrapper.svelte', () => ({ default: {} })) + +import { computeAssetNodes } from './AssetNode.svelte' + +function nodeWithAssets(id: string, assets: any[]) { + return { id, position: { x: 0, y: 0 }, data: { assets } } +} + +describe('computeAssetNodes (WIN-1998)', () => { + it('produces unique node and edge ids when a module lists the same asset twice', () => { + // Two assets with identical kind+path (e.g. read twice, or r + rw) — both + // display as inputs. Before the fix these collided on the same node id and + // crashed SvelteFlow with `each_key_duplicate`. + const dup = { kind: 'resource', path: 'f/foo/bar', access_type: 'r' } + const { newAssetNodes, newAssetEdges } = computeAssetNodes([ + nodeWithAssets('moduleA', [{ ...dup }, { ...dup }]) + ]) + + const nodeIds = newAssetNodes.map((n) => n.id) + expect(new Set(nodeIds).size).toBe(nodeIds.length) + + const edgeIds = newAssetEdges.map((e) => e.id) + expect(new Set(edgeIds).size).toBe(edgeIds.length) + }) + + it('does not emit overflow edges when there is no overflow node (<=3 assets)', () => { + const { newAssetNodes, newAssetEdges } = computeAssetNodes([ + nodeWithAssets('moduleB', [{ kind: 'resource', path: 'f/a/x', access_type: 'r' }]) + ]) + + // No overflow node should be created for a single asset... + expect(newAssetNodes.some((n) => n.type === 'assetsOverflowed')).toBe(false) + // ...and therefore no dangling edge should reference a missing overflow node. + const nodeIdSet = new Set(newAssetNodes.map((n) => n.id).concat('moduleB')) + for (const e of newAssetEdges) { + expect(nodeIdSet.has(e.source as string)).toBe(true) + expect(nodeIdSet.has(e.target as string)).toBe(true) + } + }) +}) From 2553fbfe31417bd985e7994eac695bf918f97ce2 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 28 May 2026 18:52:26 +0200 Subject: [PATCH 04/11] feat: add deepseek fim support (#9365) --- backend/windmill-ai/src/ai_providers.rs | 3 +- backend/windmill-ai/src/proxy/fim.rs | 116 ++++++++++++++++-- backend/windmill-api/src/ai.rs | 24 +++- .../copilot/autocomplete/request.ts | 4 +- frontend/src/lib/components/copilot/fim.ts | 39 ++++++ .../src/lib/components/copilot/lib.test.ts | 44 +++++++ frontend/src/lib/components/copilot/lib.ts | 23 +--- frontend/src/lib/components/copilot/utils.ts | 5 +- .../workspaceSettings/AISettings.svelte | 5 +- 9 files changed, 219 insertions(+), 44 deletions(-) create mode 100644 frontend/src/lib/components/copilot/fim.ts diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index b568c0accb..fb29454ca5 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -27,6 +27,7 @@ lazy_static::lazy_static! { } pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; /// Empty string signals BedrockClient::from_env() to use the region from AWS environment/config @@ -106,7 +107,7 @@ impl AIProvider { Ok(azure_base_path.unwrap_or("https://api.openai.com/v1".to_string())) } - AIProvider::DeepSeek => Ok("https://api.deepseek.com/v1".to_string()), + AIProvider::DeepSeek => Ok(DEEPSEEK_BASE_URL.to_string()), AIProvider::GoogleAI => Ok(GOOGLE_AI_BASE_URL.to_string()), AIProvider::Groq => Ok("https://api.groq.com/openai/v1".to_string()), AIProvider::OpenRouter => Ok("https://openrouter.ai/api/v1".to_string()), diff --git a/backend/windmill-ai/src/proxy/fim.rs b/backend/windmill-ai/src/proxy/fim.rs index 2d14fd23ce..3645476441 100644 --- a/backend/windmill-ai/src/proxy/fim.rs +++ b/backend/windmill-ai/src/proxy/fim.rs @@ -3,12 +3,13 @@ use serde::Deserialize; use serde_json::json; use windmill_common::error::{Error, Result}; -use crate::ai_providers::AIProvider; +use crate::ai_providers::{AIProvider, DEEPSEEK_BASE_URL}; #[derive(Debug, Eq, PartialEq)] pub struct FimProxyTransform { pub body: Bytes, pub path: String, + pub base_url: Option, } #[derive(Deserialize)] @@ -22,19 +23,49 @@ struct FimRequest { } pub fn supports_native_fim(provider: &AIProvider) -> bool { - matches!(provider, AIProvider::Mistral) + matches!(provider, AIProvider::Mistral | AIProvider::DeepSeek) +} + +fn deepseek_fim_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + let deepseek_root_base_url = DEEPSEEK_BASE_URL + .strip_suffix("/v1") + .unwrap_or(DEEPSEEK_BASE_URL); + + if trimmed == DEEPSEEK_BASE_URL || trimmed == deepseek_root_base_url { + return format!("{deepseek_root_base_url}/beta"); + } + + if let Some(prefix) = trimmed.strip_suffix("/v1") { + return format!("{prefix}/beta"); + } + + trimmed.to_string() } pub fn maybe_transform_fim_request( provider: &AIProvider, path: &str, + base_url: &str, body: &[u8], ) -> Result> { - if path.contains("fim/completions") && !supports_native_fim(provider) { - transform_fim_to_chat_completions(body).map(Some) - } else { - Ok(None) + if !path.contains("fim/completions") { + return Ok(None); } + + if matches!(provider, AIProvider::DeepSeek) { + return Ok(Some(FimProxyTransform { + body: Bytes::copy_from_slice(body), + path: "completions".to_string(), + base_url: Some(deepseek_fim_base_url(base_url)), + })); + } + + if !supports_native_fim(provider) { + return transform_fim_to_chat_completions(body).map(Some); + } + + Ok(None) } fn transform_fim_to_chat_completions(body: &[u8]) -> Result { @@ -64,7 +95,11 @@ fn transform_fim_to_chat_completions(body: &[u8]) -> Result { let body = serde_json::to_vec(&chat_req) .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; - Ok(FimProxyTransform { body: Bytes::from(body), path: "chat/completions".to_string() }) + Ok(FimProxyTransform { + body: Bytes::from(body), + path: "chat/completions".to_string(), + base_url: None, + }) } #[cfg(test)] @@ -73,11 +108,62 @@ mod tests { #[test] fn mistral_keeps_native_fim_request() { - let transformed = - maybe_transform_fim_request(&AIProvider::Mistral, "fim/completions", br#"{}"#).unwrap(); + let transformed = maybe_transform_fim_request( + &AIProvider::Mistral, + "fim/completions", + "https://api.mistral.ai/v1", + br#"{}"#, + ) + .unwrap(); assert!(transformed.is_none()); assert!(supports_native_fim(&AIProvider::Mistral)); + assert!(supports_native_fim(&AIProvider::DeepSeek)); + assert!(!supports_native_fim(&AIProvider::OpenAI)); + } + + #[test] + fn deepseek_fim_base_url_uses_beta_endpoint() { + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com/v1"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com/v1/"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://proxy.example/deepseek/v1"), + "https://proxy.example/deepseek/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://proxy.example/deepseek/beta"), + "https://proxy.example/deepseek/beta" + ); + } + + #[test] + fn deepseek_fim_request_uses_beta_completions_endpoint() { + let body = br#"{"model":"deepseek-v4-pro","prompt":"return ","suffix":";"}"#; + let transformed = maybe_transform_fim_request( + &AIProvider::DeepSeek, + "fim/completions", + DEEPSEEK_BASE_URL, + body, + ) + .unwrap() + .expect("DeepSeek FIM should be routed to the beta completions endpoint"); + + assert_eq!(transformed.path, "completions"); + assert_eq!( + transformed.base_url.as_deref(), + Some("https://api.deepseek.com/beta") + ); + assert_eq!(transformed.body, Bytes::copy_from_slice(body)); } #[test] @@ -85,6 +171,7 @@ mod tests { let transformed = maybe_transform_fim_request( &AIProvider::OpenAI, "fim/completions", + "https://api.openai.com/v1", br#"{ "model": "gpt-4.1", "prompt": "fn main() {", @@ -96,6 +183,7 @@ mod tests { .expect("OpenAI FIM should be transformed"); assert_eq!(transformed.path, "chat/completions"); + assert_eq!(transformed.base_url, None); let body: serde_json::Value = serde_json::from_slice(&transformed.body).unwrap(); assert_eq!(body["model"], "gpt-4.1"); @@ -111,9 +199,13 @@ mod tests { #[test] fn invalid_fim_body_is_bad_request() { - let err = - maybe_transform_fim_request(&AIProvider::OpenAI, "fim/completions", br#"{"model": 1}"#) - .unwrap_err(); + let err = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + "https://api.openai.com/v1", + br#"{"model": 1}"#, + ) + .unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); } diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 81f601e53f..21059df18a 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -627,7 +627,7 @@ async fn proxy( check_scopes(&authed, || format!("resources:read:{}", resource_path))?; } - let credentials = match workspace_cache { + let mut credentials = match workspace_cache { Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { request_cache.credentials } @@ -758,11 +758,23 @@ async fn proxy( } }; - if let Some(fim_transform) = maybe_transform_fim_request(&provider, &ai_path, &body)? { - tracing::debug!( - "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", - provider - ); + if let Some(fim_transform) = + maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)? + { + if fim_transform.base_url.is_some() { + tracing::debug!( + "Routing native FIM request through provider-specific endpoint for {:?}", + provider + ); + } else { + tracing::debug!( + "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", + provider + ); + } + if let Some(base_url) = fim_transform.base_url { + credentials.base_url = base_url; + } body = fim_transform.body; ai_path = fim_transform.path; } diff --git a/frontend/src/lib/components/copilot/autocomplete/request.ts b/frontend/src/lib/components/copilot/autocomplete/request.ts index ef29ba9a19..cc3987ebe5 100644 --- a/frontend/src/lib/components/copilot/autocomplete/request.ts +++ b/frontend/src/lib/components/copilot/autocomplete/request.ts @@ -30,9 +30,9 @@ export async function autocompleteRequest( throw new Error('No code completion model selected') } - // Only add context lines for Mistral (native FIM) - other providers use chat completion + // Only add context lines for native FIM providers - other providers use chat completion // too much context degrades significantly the quality of the completion - if (providerModel.provider === 'mistral') { + if (providerModel.provider === 'mistral' || providerModel.provider === 'deepseek') { let commentSymbol = getCommentSymbol(context.scriptLang) let contextLines = comment( commentSymbol, diff --git a/frontend/src/lib/components/copilot/fim.ts b/frontend/src/lib/components/copilot/fim.ts new file mode 100644 index 0000000000..16c5d40431 --- /dev/null +++ b/frontend/src/lib/components/copilot/fim.ts @@ -0,0 +1,39 @@ +import type { AIProvider } from '$lib/gen' +import { z } from 'zod' + +const chatFimResponseSchema = z.object({ + choices: z.array( + z.object({ + message: z.object({ + content: z.string().optional() + }), + finish_reason: z.string().optional() + }) + ) +}) + +const deepseekFimResponseSchema = z.object({ + choices: z.array( + z.object({ + text: z.string().optional(), + finish_reason: z.string().optional() + }) + ) +}) + +export function parseFimCompletionChoice( + body: unknown, + provider: AIProvider +): { content: string | undefined; finish_reason: string | undefined } | undefined { + if (provider === 'deepseek') { + const parsedBody = deepseekFimResponseSchema.parse(body) + const choice = parsedBody.choices[0] + return choice ? { content: choice.text, finish_reason: choice.finish_reason } : undefined + } + + const parsedBody = chatFimResponseSchema.parse(body) + const choice = parsedBody.choices[0] + return choice + ? { content: choice.message.content, finish_reason: choice.finish_reason } + : undefined +} diff --git a/frontend/src/lib/components/copilot/lib.test.ts b/frontend/src/lib/components/copilot/lib.test.ts index f1eec17cbb..369987d53f 100644 --- a/frontend/src/lib/components/copilot/lib.test.ts +++ b/frontend/src/lib/components/copilot/lib.test.ts @@ -9,7 +9,9 @@ import { buildAssistantToolCallMessage, getReasoningContentDelta } from './chat/openaiReasoning' +import { parseFimCompletionChoice } from './fim' import { getDefaultChatTemperature, modelDisallowsSamplingParams } from './modelConfig' +import { supportsAutocomplete } from './utils' type AssistantMessageWithReasoning = ChatCompletionMessageParam & { role: 'assistant' @@ -43,6 +45,48 @@ describe('modelConfig', () => { }) }) +describe('fim autocomplete', () => { + it('allows DeepSeek v4 pro and Codestral autocomplete models', () => { + expect(supportsAutocomplete('codestral-latest')).toBe(true) + expect(supportsAutocomplete('Codestral-2501')).toBe(true) + expect(supportsAutocomplete('codestral-embed')).toBe(false) + expect(supportsAutocomplete('deepseek-v4-pro')).toBe(true) + expect(supportsAutocomplete('deepseek-chat')).toBe(false) + }) + + it('parses chat-shaped native FIM responses', () => { + expect( + parseFimCompletionChoice( + { + choices: [ + { + message: { content: 'cache[key] = factory()' }, + finish_reason: 'stop' + } + ] + }, + 'mistral' + ) + ).toEqual({ content: 'cache[key] = factory()', finish_reason: 'stop' }) + }) + + it('parses DeepSeek native FIM completion responses', () => { + expect( + parseFimCompletionChoice( + { + choices: [ + { + text: 'items?.length ?? 0', + finish_reason: 'stop' + } + ] + }, + 'deepseek' + ) + ).toEqual({ content: 'items?.length ?? 0', finish_reason: 'stop' }) + }) +}) + describe('openaiReasoning', () => { it('reads provider-specific reasoning_content deltas', () => { expect( diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index de87579a23..00d2334acc 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -16,7 +16,6 @@ import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' import { getDefaultChatTemperature } from './modelConfig' import { formatResourceTypes } from './utils' -import { z } from 'zod' import { processToolCall, type Tool, type ToolCallbacks } from './chat/shared' import { getNonStreamingOpenAIResponsesCompletion, @@ -36,6 +35,7 @@ import { buildAssistantToolCallMessage, getReasoningContentDelta } from './chat/openaiReasoning' +import { parseFimCompletionChoice } from './fim' export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts)) @@ -74,7 +74,7 @@ export const AI_PROVIDERS: Record = { }, deepseek: { label: 'DeepSeek', - defaultModels: ['deepseek-chat', 'deepseek-reasoner'] + defaultModels: ['deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] }, googleai: { label: 'Google AI', @@ -816,17 +816,6 @@ export async function getNonStreamingCompletion( return response } -const mistralFimResponseSchema = z.object({ - choices: z.array( - z.object({ - message: z.object({ - content: z.string().optional() - }), - finish_reason: z.string() - }) - ) -}) - export const FIM_MAX_TOKENS = 256 const FIM_MAX_LINES = 8 export async function getFimCompletion( @@ -864,12 +853,10 @@ export async function getFimCompletion( ) const body = await response.json() - const parsedBody = mistralFimResponseSchema.parse(body) + const choice = parseFimCompletionChoice(body, providerModel.provider) - const choice = parsedBody.choices[0] - - if (choice && choice.message.content !== undefined) { - let lines = choice.message.content.split('\n') + if (choice?.content !== undefined) { + let lines = choice.content.split('\n') // If finish_reason is 'length', remove the last line if (choice.finish_reason === 'length') { diff --git a/frontend/src/lib/components/copilot/utils.ts b/frontend/src/lib/components/copilot/utils.ts index 8e5150cda6..50f6513b26 100644 --- a/frontend/src/lib/components/copilot/utils.ts +++ b/frontend/src/lib/components/copilot/utils.ts @@ -171,10 +171,9 @@ export function yamlStringifyExceptKeys(obj: any, keys: string[]) { /** * Checks if a model supports FIM (Fill-in-the-Middle) autocomplete. - * Currently only Codestral models (non-embedding) support this. + * Currently Codestral models (non-embedding) and DeepSeek FIM support this. */ export function supportsAutocomplete(model: string): boolean { const lower = model.toLowerCase() - return lower.includes('codestral') && !lower.includes('embed') + return (lower.includes('codestral') && !lower.includes('embed')) || lower === 'deepseek-v4-pro' } - diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index dd3cc6e0d0..922492d6ac 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -353,7 +353,7 @@ {#if showWorkspaceOverrideEditor}
- {#each Object.entries(AI_PROVIDERS) as [provider, details]} + {#each Object.entries(AI_PROVIDERS) as [provider, details] (provider)}
From 889101b7f04884408833beb05f813e78f9a9862b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 19:52:36 +0200 Subject: [PATCH 05/11] chore(main): release 1.712.0 (#9340) * chore(main): release 1.712.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 23 ++ backend/Cargo.lock | 366 ++++++++---------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +-- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 232 insertions(+), 237 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbf6cb922..583a79cd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28) + + +### Features + +* add deepseek fim support ([#9365](https://github.com/windmill-labs/windmill/issues/9365)) ([2553fbf](https://github.com/windmill-labs/windmill/commit/2553fbfe31417bd985e7994eac695bf918f97ce2)) +* deploy raw apps from global chat ([#9349](https://github.com/windmill-labs/windmill/issues/9349)) ([dec58e6](https://github.com/windmill-labs/windmill/commit/dec58e6c4f55062b42a752c43c89ef05903e713a)) +* inject active editor into global chat ([#9361](https://github.com/windmill-labs/windmill/issues/9361)) ([9e7eaf3](https://github.com/windmill-labs/windmill/commit/9e7eaf36847ad3a004ec84e8b7d4784771b7b451)) +* **queue:** duration-weighted fairness admission ([#9334](https://github.com/windmill-labs/windmill/issues/9334)) ([045d120](https://github.com/windmill-labs/windmill/commit/045d12043e7c99830ef90bc0da798c94e2094711)) +* warn when custom instance db is shared across workspaces ([#9359](https://github.com/windmill-labs/windmill/issues/9359)) ([a9e5140](https://github.com/windmill-labs/windmill/commit/a9e514099585e5ee72df21bd551a223cceb20fb0)) + + +### Bug Fixes + +* **cli:** redact encryption_key diff in stdout by default ([#9347](https://github.com/windmill-labs/windmill/issues/9347)) ([88056f8](https://github.com/windmill-labs/windmill/commit/88056f8d4c91c1d14d85a08851ecf0bd97e2260d)) +* **cli:** stop re-prompting on wmill refresh prompts ([#9357](https://github.com/windmill-labs/windmill/issues/9357)) ([c2b5ba8](https://github.com/windmill-labs/windmill/commit/c2b5ba8871abbbcff6de69c90e2f09fee70586c1)) +* **frontend:** close other sidebar menus when hovering Help ([#9354](https://github.com/windmill-labs/windmill/issues/9354)) ([da882c5](https://github.com/windmill-labs/windmill/commit/da882c54b21e3eaf2c1d1abccd0996b243d96dce)) +* **frontend:** prevent duplicate asset node ids crashing flow graph ([#9367](https://github.com/windmill-labs/windmill/issues/9367)) ([9a659b6](https://github.com/windmill-labs/windmill/commit/9a659b636d713ee8fdfbdad41c58bb3d7c79e0d9)) +* **frontend:** prevent MultiSelect crash on undefined value ([#9364](https://github.com/windmill-labs/windmill/issues/9364)) ([aea0061](https://github.com/windmill-labs/windmill/commit/aea00611c41379be2afdad0eedd608c9537d03f7)) +* **git-sync:** publish fork branch on only_create_branch from the CLI ([#9366](https://github.com/windmill-labs/windmill/issues/9366)) ([2fdc51e](https://github.com/windmill-labs/windmill/commit/2fdc51e62985fc755884436130bdd58e294247c8)) +* infer script arg schema when deploying via AI chat ([#9356](https://github.com/windmill-labs/windmill/issues/9356)) ([4efc372](https://github.com/windmill-labs/windmill/commit/4efc37212a98571214aba135b0fbb10dc263fd4f)) +* **monitor:** cleanup stale server_heartbeat background_task_state rows ([#9338](https://github.com/windmill-labs/windmill/issues/9338)) ([59ab038](https://github.com/windmill-labs/windmill/commit/59ab038d7718d8a4c25efa5928f42e1393ebbf40)) + ## [1.711.0](https://github.com/windmill-labs/windmill/compare/v1.710.1...v1.711.0) (2026-05-26) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c32e1d9c67..37fa905cb0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1141,7 +1141,7 @@ dependencies = [ "http 1.4.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", @@ -1335,7 +1335,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "itoa", "matchit 0.8.4", @@ -1684,7 +1684,7 @@ dependencies = [ "hex", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -1786,13 +1786,13 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.0", + "brotli-decompressor 5.0.1", ] [[package]] @@ -1807,9 +1807,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -2202,7 +2202,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -3599,7 +3599,7 @@ dependencies = [ "hickory-resolver", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "ipnet", @@ -3731,8 +3731,8 @@ dependencies = [ "proc-macro2", "quote", "stringcase", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "syn 2.0.117", "thiserror 2.0.18", ] @@ -3802,7 +3802,7 @@ dependencies = [ "deno_error 0.6.1", "deno_tls", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "log", @@ -4155,9 +4155,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -4364,7 +4364,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -5236,13 +5236,13 @@ dependencies = [ [[package]] name = "gosyn" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb37859fda6792e95231aef1c5838f4043ec0ee352d8313421e311c606df612" +checksum = "c99c1502d84229dc7ddb6af755f40ebe80e7e932fa78ecef979cedcf9999ba93" dependencies = [ "anyhow", - "strum 0.25.0", - "thiserror 1.0.69", + "strum", + "thiserror 2.0.18", "unic-ucd-category", ] @@ -5418,12 +5418,6 @@ dependencies = [ "http 1.4.1", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -5649,7 +5643,7 @@ dependencies = [ "futures", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.26.0", "hyper-tls", "hyper-tungstenite", @@ -5699,9 +5693,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" dependencies = [ "atomic-waker", "bytes", @@ -5729,7 +5723,7 @@ dependencies = [ "futures-util", "headers", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -5749,7 +5743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5781,7 +5775,7 @@ checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "log", "rustls 0.22.4", @@ -5799,7 +5793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "log", "rustls 0.23.35", @@ -5816,7 +5810,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5831,7 +5825,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "native-tls", "tokio", @@ -5846,7 +5840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" dependencies = [ "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5866,12 +5860,12 @@ dependencies = [ "futures-util", "http 1.4.1", "http-body 1.0.1", - "hyper 1.9.0", + "hyper 1.10.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -5887,7 +5881,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -6116,7 +6110,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result 0.4.1", @@ -6144,7 +6138,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -6406,7 +6400,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-http-proxy", "hyper-rustls 0.27.9", "hyper-timeout", @@ -6638,14 +6632,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.8.0", ] [[package]] @@ -7022,9 +7016,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -7117,9 +7111,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -7198,7 +7192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" dependencies = [ "darling 0.20.11", - "heck 0.5.0", + "heck", "num-bigint", "proc-macro-crate", "proc-macro-error2", @@ -7230,7 +7224,7 @@ dependencies = [ "percent-encoding", "rand 0.10.1", "serde", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-native-tls", @@ -7396,7 +7390,7 @@ version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71f7c8ed6ba88a567ec6f7c4cad4a7a8465ab93b8cdaf89d3dc72347a83c2d1f" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro-error", "proc-macro2", "quote", @@ -7464,7 +7458,7 @@ dependencies = [ "dirs 5.0.1", "dirs-sys 0.4.1", "fancy-regex 0.14.0", - "heck 0.5.0", + "heck", "indexmap 2.14.0", "log", "lru 0.12.5", @@ -7720,7 +7714,7 @@ dependencies = [ "http-body-util", "httparse", "humantime", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "md-5 0.10.6", "parking_lot", @@ -8245,7 +8239,7 @@ dependencies = [ "arrow-schema", "arrow-select", "base64 0.22.1", - "brotli 8.0.2", + "brotli 8.0.3", "bytes", "chrono", "flate2", @@ -8698,7 +8692,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -8979,7 +8973,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls 0.23.35", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -9017,7 +9011,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -9302,9 +9296,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" dependencies = [ "bitflags 2.11.1", ] @@ -9426,7 +9420,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -9474,7 +9468,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "js-sys", @@ -9530,7 +9524,7 @@ dependencies = [ "futures", "getrandom 0.2.17", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "reqwest 0.13.1", "reqwest-middleware", "retry-policies", @@ -10862,9 +10856,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -11052,7 +11046,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "once_cell", "proc-macro2", @@ -11268,35 +11262,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros 0.25.3", -] - [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", + "strum_macros", ] [[package]] @@ -11305,7 +11277,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -12397,7 +12369,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.9.0", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tokio-util", "whoami", @@ -12594,9 +12566,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -12629,7 +12601,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -12661,7 +12633,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -13802,7 +13774,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -13836,7 +13808,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "tikv-jemalloc-ctl", @@ -13883,7 +13855,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.711.0" +version = "1.712.0" dependencies = [ "async-stream", "async-trait", @@ -13916,7 +13888,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13929,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "argon2", @@ -13959,7 +13931,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -13990,7 +13962,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "time", @@ -14067,12 +14039,12 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "quick_cache", "serde", @@ -14090,7 +14062,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14103,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14129,7 +14101,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.711.0" +version = "1.712.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14139,7 +14111,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14156,7 +14128,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14178,7 +14150,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14201,7 +14173,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14217,11 +14189,11 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", - "hyper 1.9.0", + "hyper 1.10.0", "serde", "serde_json", "sql-builder", @@ -14238,7 +14210,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14259,7 +14231,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14273,7 +14245,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -14305,14 +14277,14 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", "base64 0.22.1", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "serde", "serde_json", @@ -14330,7 +14302,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14348,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14370,7 +14342,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14390,13 +14362,13 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "futures", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "quick_cache", @@ -14420,7 +14392,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14448,7 +14420,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.711.0" +version = "1.712.0" dependencies = [ "lazy_static", "serde", @@ -14460,14 +14432,14 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.711.0" +version = "1.712.0" dependencies = [ "argon2", "axum 0.8.9", "chrono", "dashmap", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "serde", "serde_json", @@ -14485,7 +14457,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14499,13 +14471,13 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "hex", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "magic-crypt", "regex", @@ -14513,7 +14485,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "uuid", @@ -14532,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.711.0" +version = "1.712.0" dependencies = [ "chrono", "lazy_static", @@ -14546,7 +14518,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14565,7 +14537,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.711.0" +version = "1.712.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14601,7 +14573,7 @@ dependencies = [ "globset", "hex", "hmac", - "hyper 1.9.0", + "hyper 1.10.0", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -14636,8 +14608,8 @@ dependencies = [ "sha2 0.10.9", "size", "sqlx", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "sysinfo", "systemstat", "tar", @@ -14666,7 +14638,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.711.0" +version = "1.712.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14685,7 +14657,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.711.0" +version = "1.712.0" dependencies = [ "regex", "serde", @@ -14700,7 +14672,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14724,7 +14696,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "futures", @@ -14741,7 +14713,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.711.0" +version = "1.712.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14757,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -14778,7 +14750,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -14795,7 +14767,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "urlencoding", @@ -14809,7 +14781,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "arc-swap", @@ -14834,7 +14806,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-stream", @@ -14868,7 +14840,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "futures", @@ -14886,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.711.0" +version = "1.712.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14895,7 +14867,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -14907,7 +14879,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -14919,7 +14891,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "gosyn", @@ -14931,7 +14903,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -14943,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -14955,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "nu-parser", @@ -14966,7 +14938,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14949,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14989,7 +14961,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15000,7 +14972,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15022,7 +14994,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -15034,7 +15006,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15048,7 +15020,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15065,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15078,7 +15050,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -15090,7 +15062,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15108,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15124,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15140,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -15151,7 +15123,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15189,7 +15161,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "const_format", @@ -15227,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.711.0" +version = "1.712.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15238,7 +15210,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15246,7 +15218,7 @@ dependencies = [ "chrono", "futures", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "quick_cache", "reqwest 0.13.1", @@ -15268,7 +15240,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15292,14 +15264,14 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "rand 0.9.0", @@ -15325,7 +15297,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15358,7 +15330,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15378,7 +15350,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15412,7 +15384,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15424,7 +15396,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "matchit 0.7.3", @@ -15448,7 +15420,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15471,7 +15443,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15495,7 +15467,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -15519,7 +15491,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15554,7 +15526,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15582,7 +15554,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15607,7 +15579,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15618,7 +15590,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum 0.27.2", + "strum", "tracing", "uuid", "windmill-parser", @@ -15626,7 +15598,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-once-cell", @@ -15736,7 +15708,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.711.0" +version = "1.712.0" dependencies = [ "bytes", "futures", @@ -16371,7 +16343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -16382,7 +16354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap 2.14.0", "prettyplease", "syn 2.0.117", @@ -16550,18 +16522,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e296f9fb6a..a4950571f2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.711.0" +version = "1.712.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.711.0" +version = "1.712.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index f06ab454a3..06f936e955 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.711.0" +version = "1.712.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.711.0" +version = "1.712.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.711.0" +version = "1.712.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index f49ea30ecd..2b0e060503 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.711.0" +version = "1.712.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4576f655ea..04117d640d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.711.0 + version: 1.712.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index deb8f1cf66..924052b688 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.711.0"; +export const VERSION = "v1.712.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index cc8f9e80b7..34c186182d 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -89,7 +89,7 @@ export { token, }; -export const VERSION = "1.711.0"; +export const VERSION = "1.712.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67be947bde..c59d3e5b35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index c3651f7daf..4f0c9ada75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 199fbdf672..a8f34d6189 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.711.0" +wmill = ">=1.712.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0ef3bbaa85..cb0f7d33d9 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.711.0 + version: 1.712.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index ee0f88f1b2..17ed31216b 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.711.0' + ModuleVersion = '1.712.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index ecc68d1c18..abbbed9857 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.711.0" +version = "1.712.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index c68b80d575..434effa5e0 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.711.0", + "version": "1.712.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index b4535d1f7b..c429b01434 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.711.0", + "version": "1.712.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 272c83ab90..9a8f9c885c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.711.0 +1.712.0 From 2bf11dcb15540c538ea2ac3cf70dcbe589060b4e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 29 May 2026 00:33:44 +0200 Subject: [PATCH 06/11] feat(oauth): support per-provider sandbox URLs (#9358) * feat(oauth): support per-provider sandbox URLs in registry + instance settings * fix(oauth): polish sandbox review nits (cc lookup, header label, ee ref) * refactor(oauth): drop dead build_oauth_clients duplicate in windmill-oauth * refactor(oauth): derive sandbox-capable provider list from registry * chore(docker): copy oauth_connect.json into frontend build stage * test(oauth): cover sandbox helpers (as_sandbox, canonical_name, resolve) * chore: update ee-repo-ref to 9297d8f790346e6a6ad540c7bca1a67f91ec11a2 This commit updates the EE repository reference after PR #595 was merged in windmill-ee-private. Previous ee-repo-ref: 3ab3eca9ac15ebab6db991e7964bc5e48ce21f42 New ee-repo-ref: 9297d8f790346e6a6ad540c7bca1a67f91ec11a2 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- Dockerfile | 1 + backend/ee-repo-ref.txt | 2 +- backend/oauth_connect.json | 6 +- .../windmill-common/src/instance_config.rs | 15 + backend/windmill-oauth/src/lib.rs | 393 +++++++++--------- docker/RHEL8/Dockerfile | 1 + docker/RHEL9/Dockerfile | 1 + .../src/lib/components/AppConnectInner.svelte | 35 +- .../src/lib/components/AuthSettings.svelte | 50 ++- frontend/svelte.config.js | 3 +- 10 files changed, 282 insertions(+), 225 deletions(-) diff --git a/Dockerfile b/Dockerfile index e11cf9cecd..9062a4d9d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f457a34fb7..d4a79d49d1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd +9297d8f790346e6a6ad540c7bca1a67f91ec11a2 diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index c9693b2311..d18c8c8d24 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -176,6 +176,10 @@ "token_url": "https://account.docusign.com/oauth/token", "scopes": [ "signature" - ] + ], + "sandbox": { + "auth_url": "https://account-d.docusign.com/oauth/auth", + "token_url": "https://account-d.docusign.com/oauth/token" + } } } diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 1872b52140..2239868982 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -586,6 +586,21 @@ pub struct OAuthConfig { pub req_body_auth: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub grant_types: Vec, + /// Optional URL overrides for the provider's sandbox environment. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, +} + +/// URL overrides for an OAuth provider's sandbox environment. +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] +pub struct OAuthSandboxOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_url: Option, } // --------------------------------------------------------------------------- diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index ea65a83ba4..874a859200 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -18,9 +18,7 @@ use std::collections::HashMap; use std::fmt::Debug; use anyhow::anyhow; -use base64::Engine; use hmac::Mac; -use itertools::Itertools; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sqlx::{Postgres, Transaction}; use tower_cookies::{Cookie, Cookies}; @@ -89,6 +87,76 @@ pub struct OAuthConfig { pub req_body_auth: Option, #[serde(default = "default_grant_types")] pub grant_types: Vec, + /// Optional URL overrides for the provider's sandbox environment. When + /// present and the admin has configured a `_sandbox` credentials + /// entry, `build_oauth_clients` registers a second client under that key. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, +} + +/// URL overrides for an OAuth provider's sandbox environment. Inherits +/// scopes, extra_params, etc. from the parent [`OAuthConfig`]. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct OAuthSandboxOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_url: Option, +} + +impl OAuthConfig { + /// Returns a copy of this config with sandbox URL overrides applied and + /// the nested `sandbox` field cleared. Returns `None` if no overrides are + /// set. + pub fn as_sandbox(&self) -> Option { + let sb = self.sandbox.as_ref()?; + let mut out = self.clone(); + out.sandbox = None; + if let Some(u) = &sb.auth_url { + out.auth_url = u.clone(); + } + if let Some(u) = &sb.token_url { + out.token_url = u.clone(); + } + if sb.userinfo_url.is_some() { + out.userinfo_url = sb.userinfo_url.clone(); + } + Some(out) + } +} + +/// Suffix appended to a provider name to identify its sandbox variant in the +/// instance credentials map and in `account.client`. +pub const SANDBOX_SUFFIX: &str = "_sandbox"; + +/// Strips [`SANDBOX_SUFFIX`] from a client name, returning the canonical +/// provider name. Returns the input unchanged if no suffix is present. +pub fn canonical_provider_name(client_name: &str) -> &str { + client_name + .strip_suffix(SANDBOX_SUFFIX) + .unwrap_or(client_name) +} + +/// Resolves a registry [`OAuthConfig`] for `client_name`, transparently +/// applying the `sandbox` override block when the name carries the sandbox +/// suffix (e.g. `docusign_sandbox` resolves to `docusign` with sandbox URLs +/// applied). Used so callers don't need to know whether a name is a sandbox +/// variant before looking it up. +pub fn resolve_registry_config( + static_configs: &HashMap, + client_name: &str, +) -> Option { + if let Some(cfg) = static_configs.get(client_name) { + return Some(cfg.clone()); + } + if client_name.ends_with(SANDBOX_SUFFIX) { + return static_configs + .get(canonical_provider_name(client_name)) + .and_then(|cfg| cfg.as_sandbox()); + } + None } /// OAuth client credentials @@ -181,181 +249,6 @@ pub struct OAuthCallback { pub state: String, } -/// Build all OAuth clients from configuration -pub async fn build_oauth_clients( - base_url: &str, - oauths_from_config: Option>, - connect_configs_json: &str, - login_configs_json: &str, -) -> anyhow::Result { - let connect_configs = - serde_json::from_str::>(connect_configs_json)?; - let login_configs = serde_json::from_str::>(login_configs_json)?; - - let oauths = if let Some(oauths) = oauths_from_config { - tracing::info!("Using OAuth clients from config: {oauths:?}"); - oauths - } else { - let path = "./oauth.json"; - let content: String = if let Ok(e) = std::env::var("OAUTH_JSON_AS_BASE64") { - std::str::from_utf8( - &base64::engine::general_purpose::STANDARD - .decode(e) - .map_err(to_anyhow)?, - )? - .to_string() - } else if std::path::Path::new(path).exists() { - std::fs::read_to_string(path).map_err(to_anyhow)? - } else { - tracing::warn!("oauth.json not found, no OAuth clients loaded"); - return Ok(AllClients { - logins: HashMap::new(), - connects: HashMap::new(), - slack: None, - }); - }; - - if content.is_empty() { - tracing::warn!("oauth.json is empty, no OAuth clients loaded"); - return Ok(AllClients { - logins: HashMap::new(), - connects: HashMap::new(), - slack: None, - }); - }; - match serde_json::from_str::>(&content) { - Ok(clients) => clients, - Err(e) => { - tracing::error!("deserializing oauth.json: {e}"); - HashMap::new() - } - } - .into_iter() - .collect() - }; - - tracing::info!("OAuth loaded clients: {}", oauths.keys().join(", ")); - - let logins = login_configs - .into_iter() - .filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1)))) - .chain(oauths.iter().filter_map(|x| { - x.1.login_config - .as_ref() - .map(|c| (x.0.clone(), (x.1, c.clone()))) - })) - .filter_map(|(k, (client_params, config))| { - let named_client = build_basic_client( - k.clone(), - config.clone(), - client_params.clone(), - true, - base_url, - None, - ); - named_client - .map(|named_client| { - ( - named_client.0, - ClientWithScopes { - client: named_client.1, - scopes: config.scopes.unwrap_or(vec![]), - extra_params: config.extra_params, - extra_params_callback: config.extra_params_callback, - allowed_domains: client_params.allowed_domains.clone(), - userinfo_url: config.userinfo_url, - display_name: client_params.display_name.clone(), - grant_types: client_params.grant_types.clone(), - }, - ) - }) - .map_err(|e| { - tracing::error!("Error building oauth client {k}: {e}"); - e - }) - .ok() - }) - .collect(); - - let connects = connect_configs - .into_iter() - .filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1)))) - .chain(oauths.iter().filter_map(|x| { - x.1.connect_config - .as_ref() - .map(|c| (x.0.clone(), (x.1, c.clone()))) - })) - .filter_map(|(k, (client_params, config))| { - let named_client = build_basic_client( - k.clone(), - config.clone(), - client_params.clone(), - false, - base_url, - if k == "supabase_wizard" { - Some(format!("{base_url}/oauth/callback_supabase")) - } else { - None - }, - ); - named_client - .map(|named_client| { - ( - named_client.0, - ClientWithScopes { - client: named_client.1, - scopes: config.scopes.unwrap_or(vec![]), - extra_params: config.extra_params, - extra_params_callback: config.extra_params_callback, - allowed_domains: None, - userinfo_url: None, - display_name: client_params.display_name.clone(), - grant_types: client_params.grant_types.clone(), - }, - ) - }) - .map_err(|e| { - tracing::error!("Error building oauth client {k}: {e}"); - e - }) - .ok() - }) - .collect(); - - let slack = oauths - .get("slack") - .map(|v| { - build_basic_client( - "slack".to_string(), - OAuthConfig { - auth_url: "https://slack.com/oauth/v2/authorize".to_string(), - token_url: "https://slack.com/api/oauth.v2.access".to_string(), - userinfo_url: None, - scopes: None, - extra_params: None, - extra_params_callback: None, - req_body_auth: None, - grant_types: vec!["authorization_code".to_string()], - }, - v.clone(), - false, - base_url, - Some(format!("{base_url}/oauth/callback_slack")), - ) - .map(|x| x.1) - .map_err(|e| { - tracing::error!("Error building oauth slack client: {e}"); - e - }) - .ok() - }) - .flatten(); - - let all_clients = AllClients { logins, connects, slack }; - tracing::debug!("Final oauth config: {all_clients:#?}"); - Ok(all_clients) -} - /// Build a basic OAuth client from configuration pub fn build_basic_client( name: String, @@ -433,38 +326,29 @@ pub async fn build_client_credentials_oauth_client( let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone()) .map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?; - let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { - if !config.auth_url.is_empty() && !config.token_url.is_empty() { - config.clone() - } else { - let static_configs = - serde_json::from_str::>(connect_configs_json) - .map_err(|e| { - error::Error::InternalErr(format!( - "Failed to parse oauth_connect.json: {}", - e - )) - })?; - - static_configs.get(client_name).cloned().ok_or_else(|| { - error::Error::BadRequest(format!( - "OAuth configuration not found for '{}' in either global settings or static config", - client_name - )) - })? - } - } else { - let static_configs = - serde_json::from_str::>(connect_configs_json).map_err( - |e| error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)), - )?; - - static_configs.get(client_name).cloned().ok_or_else(|| { + let parse_static_configs = || { + serde_json::from_str::>(connect_configs_json).map_err(|e| { + error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)) + }) + }; + let resolve_from_registry = |client_name: &str| -> error::Result { + let static_configs = parse_static_configs()?; + resolve_registry_config(&static_configs, client_name).ok_or_else(|| { error::Error::BadRequest(format!( "OAuth configuration not found for '{}' in either global settings or static config", client_name )) - })? + }) + }; + + let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { + if !config.auth_url.is_empty() && !config.token_url.is_empty() { + config.clone() + } else { + resolve_from_registry(client_name)? + } + } else { + resolve_from_registry(client_name)? }; if let Some(override_url) = cc_token_url_override { @@ -905,4 +789,103 @@ mod tests { let verifier = SlackVerifier::new("test_secret").unwrap(); assert!(verifier.verify("123", "body", "wrong_sig").is_err()); } + + #[test] + fn canonical_provider_name_strips_sandbox_suffix() { + assert_eq!(canonical_provider_name("docusign_sandbox"), "docusign"); + assert_eq!(canonical_provider_name("docusign"), "docusign"); + assert_eq!(canonical_provider_name(""), ""); + // Only strips the suffix once; trailing suffix on already-canonical name. + assert_eq!( + canonical_provider_name("foo_sandbox_sandbox"), + "foo_sandbox" + ); + } + + fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig { + OAuthConfig { + auth_url: "https://account.example.com/oauth/auth".to_string(), + token_url: "https://account.example.com/oauth/token".to_string(), + userinfo_url: Some("https://account.example.com/userinfo".to_string()), + scopes: Some(vec!["signature".to_string()]), + extra_params: None, + extra_params_callback: None, + req_body_auth: None, + grant_types: default_grant_types(), + sandbox: with_sandbox.then(|| OAuthSandboxOverride { + auth_url: Some("https://account-d.example.com/oauth/auth".to_string()), + token_url: Some("https://account-d.example.com/oauth/token".to_string()), + userinfo_url: None, + }), + } + } + + #[test] + fn as_sandbox_returns_none_when_no_override() { + assert!(sample_oauth_config(false).as_sandbox().is_none()); + } + + #[test] + fn as_sandbox_overlays_urls_and_inherits_rest() { + let resolved = sample_oauth_config(true).as_sandbox().unwrap(); + // URLs overridden by sandbox block + assert_eq!( + resolved.auth_url, + "https://account-d.example.com/oauth/auth" + ); + assert_eq!( + resolved.token_url, + "https://account-d.example.com/oauth/token" + ); + // userinfo_url not in override → inherits from parent + assert_eq!( + resolved.userinfo_url, + Some("https://account.example.com/userinfo".to_string()) + ); + // Scopes/grant_types inherited from parent + assert_eq!(resolved.scopes, Some(vec!["signature".to_string()])); + assert_eq!(resolved.grant_types, default_grant_types()); + // Nested sandbox field cleared on the resolved config + assert!(resolved.sandbox.is_none()); + } + + #[test] + fn resolve_registry_config_direct_lookup() { + let mut registry = HashMap::new(); + registry.insert("docusign".to_string(), sample_oauth_config(true)); + + let resolved = resolve_registry_config(®istry, "docusign").unwrap(); + assert_eq!(resolved.auth_url, "https://account.example.com/oauth/auth"); + // Direct lookup returns the entry as-is (sandbox block still attached). + assert!(resolved.sandbox.is_some()); + } + + #[test] + fn resolve_registry_config_sandbox_fallback() { + let mut registry = HashMap::new(); + registry.insert("docusign".to_string(), sample_oauth_config(true)); + + let resolved = resolve_registry_config(®istry, "docusign_sandbox").unwrap(); + // Sandbox-suffixed lookup resolves to parent's sandbox-overlaid config. + assert_eq!( + resolved.auth_url, + "https://account-d.example.com/oauth/auth" + ); + assert!(resolved.sandbox.is_none()); + } + + #[test] + fn resolve_registry_config_missing_returns_none() { + let registry: HashMap = HashMap::new(); + assert!(resolve_registry_config(®istry, "docusign").is_none()); + assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); + } + + #[test] + fn resolve_registry_config_sandbox_without_block_returns_none() { + let mut registry = HashMap::new(); + // Parent exists but has no sandbox override. + registry.insert("docusign".to_string(), sample_oauth_config(false)); + assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); + } } diff --git a/docker/RHEL8/Dockerfile b/docker/RHEL8/Dockerfile index cb5f36cef5..500050de67 100644 --- a/docker/RHEL8/Dockerfile +++ b/docker/RHEL8/Dockerfile @@ -30,6 +30,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/docker/RHEL9/Dockerfile b/docker/RHEL9/Dockerfile index 6d96804381..a0fff8dd91 100644 --- a/docker/RHEL9/Dockerfile +++ b/docker/RHEL9/Dockerfile @@ -30,6 +30,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 4f0a5a76b2..7b6ed37ecc 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -74,6 +74,16 @@ let value: string = $state('') let valueToken: TokenResponse | undefined = undefined let connects: string[] | undefined = $state(undefined) + + const SANDBOX_SUFFIX = '_sandbox' + function stripSandboxSuffix(name: string): string { + return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name + } + // `resourceType` is always the canonical type (e.g. `docusign`) so resource + // rows are uniform. `connectClient` carries the suffixed OAuth client name + // (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime + // and stored on `account.client` so token refresh hits the right endpoint. + let connectClient: string = $state('') let connectsManual: { key: string; img?: string; instructions: string[] }[] | undefined = $state(undefined) let args: any = $state({}) @@ -152,7 +162,9 @@ description = '' labels = undefined wsSpecific = false - resourceType = rt ?? '' + const rawRt = rt ?? '' + connectClient = rawRt + resourceType = stripSandboxSuffix(rawRt) valueToken = undefined // Reset client credentials state @@ -163,7 +175,7 @@ tokenUrl = '' await loadConnects() - manual = !connects?.includes(resourceType) + manual = !connects?.includes(connectClient) if (manual && express) { dispatch('error', 'Express OAuth setup is not available for non OAuth resource types') return @@ -312,7 +324,8 @@ sendUserToast(data.error, true) step = 2 } else if (data.type === 'success') { - resourceType = data.resource_type + connectClient = data.resource_type + resourceType = stripSandboxSuffix(connectClient) value = data.res.access_token! valueToken = data.res responseExtra = data.extra ?? {} @@ -325,7 +338,7 @@ } async function getScopesAndParams() { - const connect = await OauthService.getOauthConnect({ client: resourceType }) + const connect = await OauthService.getOauthConnect({ client: connectClient }) scopes = connect.scopes ?? [] extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][] @@ -401,7 +414,7 @@ } const tokenResponse = await OauthService.connectClientCredentials({ - client: resourceType, + client: connectClient, requestBody }) @@ -428,7 +441,7 @@ * Requires user interaction and consent * Opens popup for user to authenticate with OAuth provider */ - const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin) + const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin) url.searchParams.append('scopes', scopes.join('+')) if (extra_params.length > 0) { extra_params.forEach(([key, value]) => url.searchParams.append(key, value)) @@ -490,7 +503,7 @@ const accountData: any = { refresh_token: valueToken.refresh_token ?? '', expires_in: valueToken.expires_in, - client: resourceType, + client: connectClient, grant_type: valueToken.grant_type || 'authorization_code' } @@ -602,6 +615,7 @@ ) step = 1 resourceType = '' + connectClient = '' } } @@ -660,10 +674,11 @@