Merge remote-tracking branch 'origin/main' into remove-workspace-drafts

This commit is contained in:
Diego Imbert
2026-05-29 15:49:21 +02:00
67 changed files with 1949 additions and 1268 deletions
+3 -1
View File
@@ -8,6 +8,7 @@ on:
- "backend/windmill-git-sync/**"
- "backend/windmill-api-integration-tests/tests/git_sync*"
- "backend/ee-repo-ref.txt"
- "backend/windmill-common/src/workspaces.rs"
- "integration_tests/test/git_sync_test.py"
- ".github/workflows/git-sync-test.yml"
pull_request:
@@ -16,6 +17,7 @@ on:
- "backend/windmill-git-sync/**"
- "backend/windmill-api-integration-tests/tests/git_sync*"
- "backend/ee-repo-ref.txt"
- "backend/windmill-common/src/workspaces.rs"
- "integration_tests/test/git_sync_test.py"
- ".github/workflows/git-sync-test.yml"
@@ -49,7 +51,7 @@ jobs:
echo "$CHANGED_FILES"
# Direct git sync file changes — always relevant
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Relevant: direct git sync file changes"
exit 0
+23
View File
@@ -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)
+1
View File
@@ -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
+5 -2
View File
@@ -88,15 +88,18 @@ Today:
- `sonnet`
- `opus`
- `4o`
- `gpt-5.5`
- `gemini-flash`
- `gemini-pro`
- `gemini-3-flash-preview`
- `gemini-3.1-pro-preview`
- `deepseek-v4-flash`
- `deepseek-v4-pro`
Notes:
- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5`
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases
- the command also prints accepted alias spellings such as `gpt-4o`, `gpt-55`, `claude-opus-4.6`, and `claude-haiku-4.5`
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, Gemini, and DeepSeek-backed aliases
- `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there
- the judge model is separate and currently defaults to `claude-sonnet-4-6`
+15
View File
@@ -2,6 +2,21 @@ import { describe, expect, it } from "bun:test";
import { resolveEvalModel } from "./models";
describe("resolveEvalModel", () => {
it("supports GPT-5.5 aliases for frontend evals", () => {
expect(resolveEvalModel("flow", "gpt-5.5").frontend).toEqual({
provider: "openai",
model: "gpt-5.5",
});
expect(resolveEvalModel("app", "gpt-55").frontend).toEqual({
provider: "openai",
model: "gpt-5.5",
});
expect(resolveEvalModel("script", "5.5").frontend).toEqual({
provider: "openai",
model: "gpt-5.5",
});
});
it("supports Gemini aliases for frontend evals", () => {
expect(resolveEvalModel("flow", "gemini").frontend).toEqual({
provider: "googleai",
+9
View File
@@ -87,6 +87,15 @@ export const EVAL_MODELS: EvalModelSpec[] = [
model: "gpt-4o",
},
},
{
id: "gpt-5.5",
label: "GPT-5.5",
aliases: ["gpt-5.5", "gpt-55", "5.5"],
frontend: {
provider: "openai",
model: "gpt-5.5",
},
},
{
id: "gemini-flash",
label: "Gemini 2.5 Flash",
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account)\n VALUES ($1, $2, $3, true, '', '{}'::jsonb, NULL)\n ON CONFLICT (workspace_id, path) DO UPDATE SET value = EXCLUDED.value\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT j.id, j.args\n FROM v2_job j\n JOIN v2_job_queue q ON j.id = q.id\n WHERE j.runnable_path = $1\n AND j.kind = 'deploymentcallback'\n AND j.workspace_id = 'test-workspace'\n ORDER BY j.created_at DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "args",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5"
}
+169 -197
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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 <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd
08e3b9b818f9f1b439e9a6ba477fd5fdbcab944e
+5 -1
View File
@@ -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"
}
}
}
+24 -24
View File
@@ -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",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.711.0"
version = "1.712.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+36
View File
@@ -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<String> {
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(_))
+2 -1
View File
@@ -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()),
+25
View File
@@ -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<String>,
pub access_token: Option<String>,
pub organization_id: Option<String>,
pub user: Option<String>,
pub region: Option<String>,
pub aws_access_key_id: Option<String>,
pub aws_secret_access_key: Option<String>,
pub aws_session_token: Option<String>,
pub platform: AIPlatform,
pub enable_1m_context: bool,
pub custom_headers: HashMap<String, String>,
}
+1
View File
@@ -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;
@@ -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;
+228 -128
View File
@@ -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<usize, (String, String, String)>,
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<usize, (String, String, String)>,
tool_block_indexes: HashMap<usize, usize>,
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<Bytes> {
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
);
}
}
@@ -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 {
+3 -1
View File
@@ -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,
+6 -22
View File
@@ -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<String>,
pub access_token: Option<String>,
pub organization_id: Option<String>,
pub user: Option<String>,
pub region: Option<String>,
pub aws_access_key_id: Option<String>,
pub aws_secret_access_key: Option<String>,
pub aws_session_token: Option<String>,
pub platform: AIPlatform,
pub enable_1m_context: bool,
pub custom_headers: HashMap<String, String>,
}
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<Vec<u8>> {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use crate::ai_providers::AIPlatform;
fn credentials(provider: AIProvider, base_url: &str) -> ProviderCredentials {
ProviderCredentials {
+212
View File
@@ -0,0 +1,212 @@
use bytes::Bytes;
use serde::Deserialize;
use serde_json::json;
use windmill_common::error::{Error, Result};
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<String>,
}
#[derive(Deserialize)]
struct FimRequest {
model: String,
prompt: String,
suffix: Option<String>,
temperature: Option<f32>,
max_tokens: Option<u32>,
stop: Option<Vec<String>>,
}
pub fn supports_native_fim(provider: &AIProvider) -> bool {
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<Option<FimProxyTransform>> {
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<FimProxyTransform> {
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 <CURSOR/> 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!(
"<PREFIX>\n{}\n<CURSOR/>\n<SUFFIX>\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(),
base_url: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mistral_keeps_native_fim_request() {
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]
fn openai_fim_request_is_transformed_to_chat_completion() {
let transformed = maybe_transform_fim_request(
&AIProvider::OpenAI,
"fim/completions",
"https://api.openai.com/v1",
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");
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");
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"],
"<PREFIX>\nfn main() {\n<CURSOR/>\n<SUFFIX>\n}"
);
}
#[test]
fn invalid_fim_body_is_bad_request() {
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(_)));
}
}
+1 -1
View File
@@ -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;
@@ -0,0 +1,358 @@
/*!
* Integration test for workspace encryption key rotation triggering git sync.
*
* Regression test for windmill-labs/windmill#9344 — re-encrypting all secret
* variables on workspace key change must dispatch a git-sync job that carries
* every re-encrypted variable plus the encryption_key entry, so repos with
* Secrets sync enabled receive the new ciphertexts in one commit.
*
* Run with enterprise features:
* ```bash
* cargo test --test workspace_encryption_key_git_sync --features enterprise,private
* ```
*/
use serde_json::json;
use sqlx::{Pool, Postgres};
use std::time::Duration;
#[allow(unused_imports)]
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
#[allow(dead_code)]
async fn create_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/test_git_repo', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user')
ON CONFLICT (workspace_id, path) DO NOTHING
"#,
)
.bind(json!({
"url": "https://github.com/test/test.git",
"branch": "main",
"token": "test-token"
}))
.execute(db)
.await?;
Ok(())
}
#[allow(dead_code)]
async fn create_folder(db: &Pool<Postgres>, name: &str) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by)
VALUES ('test-workspace', $1, $1, ARRAY['u/test-user'], '{}'::jsonb, 'test-user')
ON CONFLICT (workspace_id, name) DO NOTHING
"#,
)
.bind(name)
.execute(db)
.await?;
Ok(())
}
#[allow(dead_code)]
async fn create_sync_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
sqlx::query(
r#"
INSERT INTO script (workspace_id, hash, path, summary, description, content,
created_by, language, kind, lock)
VALUES ('test-workspace', $1, $2, 'sync script', '',
'export function main(items: any[]) { return { synced: items.length }; }',
'test-user', 'bun', 'script', '')
"#,
)
.bind(hash)
.bind(path)
.execute(db)
.await?;
Ok(hash)
}
#[allow(dead_code)]
async fn setup_git_sync_config(db: &Pool<Postgres>, sync_script_path: &str) -> anyhow::Result<()> {
// Include Variable + Secret + Key so the encryption rotation has a reason
// to push every re-encrypted variable. Anchor include_path to root so all
// u/... and f/... paths pass the regex filter.
let git_sync_config = json!({
"include_type": ["variable", "secret", "key"],
"include_path": ["**"],
"repositories": [{
"script_path": sync_script_path,
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
"use_individual_branch": false,
"group_by_folder": false
}]
});
sqlx::query!(
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
git_sync_config,
"test-workspace"
)
.execute(db)
.await?;
Ok(())
}
/// Insert N secret variables, encrypting their values with the workspace's
/// current key so the re-encryption path can decrypt them.
#[allow(dead_code)]
async fn insert_secret_variables(db: &Pool<Postgres>, paths: &[&str]) -> anyhow::Result<()> {
use windmill_common::variables::{build_crypt, encrypt};
let mc = build_crypt(db, "test-workspace").await?;
for path in paths {
let plaintext = format!("secret-value-for-{path}");
let encrypted = encrypt(&mc, &plaintext);
sqlx::query!(
r#"
INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account)
VALUES ($1, $2, $3, true, '', '{}'::jsonb, NULL)
ON CONFLICT (workspace_id, path) DO UPDATE SET value = EXCLUDED.value
"#,
"test-workspace",
path,
encrypted,
)
.execute(db)
.await?;
}
Ok(())
}
#[derive(Debug)]
#[allow(dead_code)]
struct DeploymentCallbackJob {
id: uuid::Uuid,
args: Option<serde_json::Value>,
}
/// Poll until at least `min_count` deployment-callback jobs exist for the
/// script path, or the timeout elapses. Returns whatever was found.
#[allow(dead_code)]
async fn wait_for_deployment_callbacks(
db: &Pool<Postgres>,
script_path: &str,
min_count: usize,
timeout: Duration,
) -> anyhow::Result<Vec<DeploymentCallbackJob>> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let rows = sqlx::query_as!(
DeploymentCallbackJob,
r#"
SELECT j.id, j.args
FROM v2_job j
JOIN v2_job_queue q ON j.id = q.id
WHERE j.runnable_path = $1
AND j.kind = 'deploymentcallback'
AND j.workspace_id = 'test-workspace'
ORDER BY j.created_at DESC
"#,
script_path,
)
.fetch_all(db)
.await?;
if rows.len() >= min_count || tokio::time::Instant::now() >= deadline {
return Ok(rows);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_encryption_key_rotation_dispatches_batched_git_sync(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
// Setup git sync repo + sync script (folder/path encodes the hub min-version)
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script_encryption";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
let secret_paths = [
"u/test-user/secret_a",
"u/test-user/secret_b",
"u/test-user/secret_c",
];
insert_secret_variables(&db, &secret_paths).await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
// 64-char alphanumeric per the route's WORKSPACE_KEY_REGEXP
let new_key = "a".repeat(64);
let resp = authed(client().post(format!("{base}/encryption_key")))
.json(&json!({"new_key": new_key, "skip_reencrypt": false}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"set_encryption_key failed: {}",
resp.text().await?
);
// The git-sync dispatch runs in a tokio::spawn'd task. Poll up to a few
// seconds for the deployment callback to land in the queue.
let jobs =
wait_for_deployment_callbacks(&db, sync_script_path, 1, Duration::from_secs(5)).await?;
assert_eq!(
jobs.len(),
1,
"expected exactly one batched deployment callback job, got {}",
jobs.len()
);
let job = &jobs[0];
let args = job.args.as_ref().expect("job should have args");
let items = args
.get("items")
.and_then(|v| v.as_array())
.expect("args.items should be a JSON array");
// Expect exactly one job carrying the Key entry + every re-encrypted variable
assert_eq!(
items.len(),
secret_paths.len() + 1,
"expected {} items (key + {} variables) in a single sync job, got {} — items: {:#?}",
secret_paths.len() + 1,
secret_paths.len(),
items.len(),
items
);
let mut variable_paths: Vec<String> = Vec::new();
let mut saw_key = false;
for item in items {
let path_type = item.get("path_type").and_then(|v| v.as_str()).unwrap_or("");
let path = item.get("path").and_then(|v| v.as_str()).unwrap_or("");
match path_type {
"variable" => variable_paths.push(path.to_string()),
"key" => saw_key = true,
other => panic!("unexpected path_type in batch: {other}"),
}
}
assert!(
saw_key,
"expected a path_type=key entry in items: {:#?}",
items
);
variable_paths.sort();
let mut expected: Vec<String> = secret_paths.iter().map(|s| s.to_string()).collect();
expected.sort();
assert_eq!(
variable_paths, expected,
"items array should contain every re-encrypted secret variable"
);
// Secrets sync is enabled (ObjectType::Secret in include_type), so the sync
// script should be invoked with skip_secret=false.
let skip_secret = args
.get("skip_secret")
.and_then(|v| v.as_bool())
.expect("args.skip_secret should be set when batch carries variables");
assert!(
!skip_secret,
"skip_secret should be false when Secret is included in the repo's types"
);
Ok(())
}
/// Regression test for the non-debouncing fallback: a workspace whose sync
/// script predates hub version 28103 must still receive git-sync jobs for the
/// encryption_key entry and every re-encrypted secret. Before the fallback was
/// added, the batch path `continue`d past such repos and queued nothing,
/// silently leaving the repo stale after a key rotation.
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_encryption_key_rotation_falls_back_without_debouncing(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
// Folder/path encodes a hub version BELOW 28103, so
// is_script_meets_min_version(28103) is false → debouncing unsupported.
create_folder(&db, "28000").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28000/test_sync_script_legacy";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
let secret_paths = ["u/test-user/secret_a", "u/test-user/secret_b"];
insert_secret_variables(&db, &secret_paths).await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let new_key = "b".repeat(64);
let resp = authed(client().post(format!("{base}/encryption_key")))
.json(&json!({"new_key": new_key, "skip_reencrypt": false}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"set_encryption_key failed: {}",
resp.text().await?
);
// Legacy fallback pushes one job per item (flat args, no `items` array):
// the Key entry + one per re-encrypted variable.
let expected = secret_paths.len() + 1;
let jobs =
wait_for_deployment_callbacks(&db, sync_script_path, expected, Duration::from_secs(5))
.await?;
assert_eq!(
jobs.len(),
expected,
"expected {expected} legacy deployment-callback jobs (key + {} variables), got {} — a repo on an old sync script must not be silently skipped",
secret_paths.len(),
jobs.len()
);
let mut variable_paths: Vec<String> = Vec::new();
let mut saw_key = false;
for job in &jobs {
let args = job.args.as_ref().expect("job should have args");
// Legacy format: flat fields, never an `items` array.
assert!(
args.get("items").is_none(),
"fallback jobs must use the flat legacy format, not an items array: {args:#?}"
);
let path_type = args.get("path_type").and_then(|v| v.as_str()).unwrap_or("");
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
match path_type {
"variable" => variable_paths.push(path.to_string()),
"key" => saw_key = true,
other => panic!("unexpected path_type in fallback job: {other}"),
}
}
assert!(saw_key, "expected a path_type=key fallback job");
variable_paths.sort();
let mut expected_paths: Vec<String> = secret_paths.iter().map(|s| s.to_string()).collect();
expected_paths.sort();
assert_eq!(
variable_paths, expected_paths,
"fallback must queue a job for every re-encrypted secret variable"
);
Ok(())
}
@@ -55,7 +55,10 @@ use windmill_common::{
use windmill_dep_map::scoped_dependency_map::{
DependencyDependent, DependencyMap, ScopedDependencyMap,
};
use windmill_git_sync::{handle_deployment_metadata, handle_fork_branch_creation, DeployedObject};
use windmill_git_sync::{
handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation,
DeployedObject,
};
use windmill_types::s3::LargeFileStorage;
use hyper::StatusCode;
@@ -3403,6 +3406,7 @@ async fn set_encryption_key(
.execute(&mut *tx)
.await?;
let mut reencrypted_secret_paths: Vec<String> = Vec::new();
if !request.skip_reencrypt.unwrap_or(false) {
// Build the new cipher directly from the key string, since the transaction
// hasn't committed yet and build_crypt() would read the old key from the pool.
@@ -3448,6 +3452,7 @@ async fn set_encryption_key(
)
.execute(&mut *tx)
.await?;
reencrypted_secret_paths.push(variable.path);
}
}
@@ -3456,16 +3461,23 @@ async fn set_encryption_key(
// Invalidate the cache only after the transaction has committed
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
// Trigger git sync for encryption key changes
handle_deployment_metadata(
// Build the batch: one event for the encryption key itself plus one per
// re-encrypted secret variable. The batch entrypoint dispatches a single
// git-sync job per repo carrying all items, so repos with Secrets sync
// enabled receive the new ciphertexts in one commit.
let mut batch: Vec<DeployedObject> = Vec::with_capacity(reencrypted_secret_paths.len() + 1);
batch.push(DeployedObject::Key { key_type: "encryption_key".to_string() });
for path in reencrypted_secret_paths {
batch.push(DeployedObject::Variable { path: path.clone(), parent_path: Some(path) });
}
handle_deployment_metadata_batch(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::Key { key_type: "encryption_key".to_string() },
batch,
Some("Encryption key updated".to_string()),
false,
None,
)
.await?;
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.711.0
version: 1.712.0
title: Windmill API
contact:
+109 -108
View File
@@ -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};
@@ -108,6 +108,13 @@ lazy_static::lazy_static! {
.timeout(std::time::Duration::from_secs(*AI_TIMEOUT_SECS))
.pool_max_idle_per_host(HTTP_POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(Some(std::time::Duration::from_secs(HTTP_POOL_IDLE_TIMEOUT_SECS)))
// The SSRF check in `get_base_url` only validates the configured `base_url`.
// reqwest follows up to 10 redirects by default and does not revalidate the
// hops, so a public base_url could 3xx the server into a private/internal
// address. Disable redirect following so the validated host is the only one
// we ever connect to. AI APIs respond directly and do not rely on redirects,
// so this holds even for ALLOW_PRIVATE_AI_BASE_URLS deployments.
.redirect(reqwest::redirect::Policy::none())
.user_agent("windmill/beta"))
.build()
.expect("Failed to build AI HTTP client - check system TLS configuration");
@@ -369,53 +376,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<String>, // code after cursor
temperature: Option<f32>,
max_tokens: Option<u32>,
stop: Option<Vec<String>>,
}
/// 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 <CURSOR/> 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!(
"<PREFIX>\n{}\n<CURSOR/>\n<SUFFIX>\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 +415,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 +508,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());
@@ -641,7 +634,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
}
@@ -772,17 +765,25 @@ 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) {
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;
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;
}
let proxy_mode = proxy_execution_mode(&provider);
+50 -15
View File
@@ -7106,7 +7106,11 @@ pub async fn run_job_by_hash_inner(
Ok((uuid, delete_after_use, delete_after_secs))
}
async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::Result<Response> {
async fn get_log_file(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, file_p)): Path<(String, String)>,
) -> error::Result<Response> {
if file_p.contains("..") {
return Err(error::Error::BadRequest("Invalid path".to_string()));
}
@@ -7118,27 +7122,58 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R
"Invalid path: must have exactly 2 components".to_string(),
));
}
if Uuid::parse_str(parts[0]).is_err() {
return Err(error::Error::BadRequest(
"Invalid path: first component must be a valid UUID".to_string(),
));
}
let job_id = Uuid::parse_str(parts[0]).map_err(|_| {
error::Error::BadRequest("Invalid path: first component must be a valid UUID".to_string())
})?;
if !parts[1].ends_with(".txt") {
return Err(error::Error::BadRequest(
"Invalid path: file must end with .txt".to_string(),
));
}
// Authorization: the log file directory is the job id, so gate access the same
// way as get_job_logs — the caller must be able to read the job. Non-logged-in
// callers may only read logs of jobs created by the anonymous user.
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec()))
.flatten();
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))",
job_id,
w_id,
tags.as_ref().map(|v| v.as_slice())
)
.fetch_optional(&db)
.await?
.ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?;
if opt_authed.is_none() && created_by != "anonymous" {
return Err(error::Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
}
let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR);
if tokio::fs::metadata(&local_file).await.is_ok() {
let mut file = tokio::fs::File::open(local_file).await.map_err(to_anyhow)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(to_anyhow)?;
let res = Response::builder()
.header(http::header::CONTENT_TYPE, "text/plain")
.body(Body::from(bytes::Bytes::from(buffer)))
.unwrap();
return Ok(res);
// SECURITY (defense in depth): refuse to read through a symlink so a planted
// symlink in the logs directory cannot be used to exfiltrate arbitrary files.
// `symlink_metadata` returns the link's own metadata without following it.
match tokio::fs::symlink_metadata(&local_file).await {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(error::Error::BadRequest("Invalid path".to_string()));
}
Ok(_) => {
let mut file = tokio::fs::File::open(&local_file)
.await
.map_err(to_anyhow)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(to_anyhow)?;
let res = Response::builder()
.header(http::header::CONTENT_TYPE, "text/plain")
.body(Body::from(bytes::Bytes::from(buffer)))
.unwrap();
return Ok(res);
}
Err(_) => {}
}
#[cfg(all(feature = "enterprise", feature = "parquet"))]
+12 -1
View File
@@ -133,7 +133,18 @@ async fn get_log_file(
}
}
}
let file = tokio::fs::read(format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path)).await;
let full_path = format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path);
// SECURITY (defense in depth): refuse to read through a symlink so a planted
// symlink in the logs directory cannot be used to exfiltrate arbitrary files.
// `symlink_metadata` returns the link's own metadata without following it.
match tokio::fs::symlink_metadata(&full_path).await {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(Error::BadRequest("Invalid path".to_string()));
}
Ok(_) => {}
Err(_) => return Err(Error::NotFound(format!("File {path} not found"))),
}
let file = tokio::fs::read(&full_path).await;
if let Ok(bytes) = file {
Ok(content_plain(Body::from(bytes::Bytes::from(bytes))))
} else {
@@ -586,6 +586,21 @@ pub struct OAuthConfig {
pub req_body_auth: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grant_types: Vec<String>,
/// Optional URL overrides for the provider's sandbox environment.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<OAuthSandboxOverride>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_url: Option<String>,
}
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -157,7 +157,7 @@ pub enum ObjectType {
WorkspaceDependencies,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28236/sync-script-to-git-repo-windmill";
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28238/sync-script-to-git-repo-windmill";
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
/// fork of another workspace.
@@ -33,3 +33,15 @@ pub async fn handle_fork_branch_creation<'c>(
) -> Result<Vec<uuid::Uuid>> {
return Ok(vec![]);
}
#[cfg(not(feature = "private"))]
pub async fn handle_deployment_metadata_batch<'c>(
_email: &str,
_created_by: &str,
_db: &DB,
_w_id: &str,
_objs: Vec<DeployedObject>,
_deployment_message: Option<String>,
) -> Result<()> {
return Ok(());
}
+6 -2
View File
@@ -13,10 +13,14 @@ pub mod git_sync_ee;
pub mod git_sync_oss;
#[cfg(feature = "private")]
pub use git_sync_ee::{handle_deployment_metadata, handle_fork_branch_creation};
pub use git_sync_ee::{
handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation,
};
#[cfg(not(feature = "private"))]
pub use git_sync_oss::{handle_deployment_metadata, handle_fork_branch_creation};
pub use git_sync_oss::{
handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation,
};
#[derive(Clone, Debug)]
pub enum DeployedObject {
+188 -205
View File
@@ -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<bool>,
#[serde(default = "default_grant_types")]
pub grant_types: Vec<String>,
/// Optional URL overrides for the provider's sandbox environment. When
/// present and the admin has configured a `<name>_sandbox` credentials
/// entry, `build_oauth_clients` registers a second client under that key.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<OAuthSandboxOverride>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_url: Option<String>,
}
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<OAuthConfig> {
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<String, OAuthConfig>,
client_name: &str,
) -> Option<OAuthConfig> {
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<HashMap<String, OAuthClient>>,
connect_configs_json: &str,
login_configs_json: &str,
) -> anyhow::Result<AllClients> {
let connect_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)?;
let login_configs = serde_json::from_str::<HashMap<String, OAuthConfig>>(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::<HashMap<String, OAuthClient>>(&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::<HashMap<String, OAuthConfig>>(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::<HashMap<String, OAuthConfig>>(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::<HashMap<String, OAuthConfig>>(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<OAuthConfig> {
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(&registry, "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(&registry, "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<String, OAuthConfig> = HashMap::new();
assert!(resolve_registry_config(&registry, "docusign").is_none());
assert!(resolve_registry_config(&registry, "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(&registry, "docusign_sandbox").is_none());
}
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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},
};
+1 -1
View File
@@ -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<string> {
return await windmill.UserService.login({
+14 -2
View File
@@ -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;
}
}
+1 -1
View File
@@ -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";
+71
View File
@@ -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>/<id>` 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 });
});
},
);
+1
View File
@@ -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
+1
View File
@@ -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
-441
View File
@@ -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<dyn StreamEventSink>`.
---
### 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<ProxyRequest, Error>;
```
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<u8>,
}
```
**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<String>,
pub access_token: Option<String>,
pub organization_id: Option<String>,
pub user: Option<String>,
pub platform: AIPlatform,
pub region: Option<String>,
pub aws_access_key_id: Option<String>,
pub aws_secret_access_key: Option<String>,
pub aws_session_token: Option<String>,
pub enable_1m_context: bool,
pub custom_headers: HashMap<String, String>,
}
```
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.
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -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",
@@ -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 @@
<Button
unifiedSize="md"
variant="default"
selected={key === resourceType}
selected={key === connectClient}
on:click={() => {
manual = false
resourceType = key
connectClient = key
resourceType = stripSandboxSuffix(key)
next()
}}
>
@@ -703,6 +718,7 @@
selected={key === resourceType}
on:click={() => {
manual = true
connectClient = key
resourceType = key
next()
}}
@@ -725,6 +741,7 @@
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
on:click={() => {
manual = true
connectClient = key
resourceType = key
next()
}}
@@ -26,6 +26,7 @@
import { tick } from 'svelte'
import { Popover } from './meltComponents'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import oauthConnectRegistry from '$oauth_connect_registry'
interface Props {
snowflakeAccountIdentifier?: string
@@ -59,7 +60,7 @@
}
})
const windmillBuiltins = [
const windmillBuiltinsBase = [
'azure_oauth',
'github',
'gitlab',
@@ -82,7 +83,21 @@
'teams',
'zoho',
'xero',
'apify'
'apify',
'docusign'
]
// Providers whose registry entry (`backend/oauth_connect.json`) carries a
// `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown
// entry and is treated as a builtin so we don't render the custom-URL form
// — the URLs come from the registry sandbox block. Derived at build time
// from the registry so adding a sandbox to a provider needs no frontend
// change.
const windmillBuiltinsWithSandbox = Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'sandbox' in cfg)
.map(([name]) => name)
const windmillBuiltins = [
...windmillBuiltinsBase,
...windmillBuiltinsWithSandbox.map((n) => `${n}_sandbox`)
]
let showCustomOAuthForm = $state(false)
@@ -175,26 +190,29 @@
}
function getOAuthProviderIcon(name: string) {
// Sandbox variants share the parent provider's icon.
const lookup = name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name
// Handle special cases
if (name === 'teams') {
if (lookup === 'teams') {
return APP_TO_ICON_COMPONENT.ms_teams_webhook
}
if (name === 'snowflake_oauth') {
if (lookup === 'snowflake_oauth') {
return APP_TO_ICON_COMPONENT.snowflake
}
if (name === 'azure_oauth') {
if (lookup === 'azure_oauth') {
return APP_TO_ICON_COMPONENT.azure
}
// Try direct mapping, fallback to Circle icon if not found
return APP_TO_ICON_COMPONENT[name as keyof typeof APP_TO_ICON_COMPONENT] || Circle
return APP_TO_ICON_COMPONENT[lookup as keyof typeof APP_TO_ICON_COMPONENT] || Circle
}
function generateOAuthDropdownItems(): Item[] {
const items: Item[] = []
// Add built-in providers that are not already configured
windmillBuiltins.forEach((name) => {
windmillBuiltinsBase.forEach((name) => {
// Only show providers that are not already in the oauths object
if (!oauths || !oauths[name]) {
const icon = getOAuthProviderIcon(name)
@@ -206,6 +224,19 @@
}
})
// Add sandbox variants for providers that have sandbox URLs in the registry
windmillBuiltinsWithSandbox.forEach((name) => {
const sandboxKey = `${name}_sandbox`
if (!oauths || !oauths[sandboxKey]) {
const icon = getOAuthProviderIcon(name)
items.push({
displayName: `${capitalize(name)} (sandbox)`,
action: () => createOAuthClient(sandboxKey),
icon: icon
})
}
})
// Add custom option
items.push({
displayName: `Custom OAuth client ${!$enterpriseLicense ? '(requires ee)' : ''}`,
@@ -370,11 +401,14 @@
{#if oauths[k] && !(oauths[k] && 'login_config' in oauths[k])}
{#if !['slack', 'teams'].includes(k) && oauths[k]}
{@const IconComponent = getOAuthProviderIcon(k) as any}
{@const headerLabel = k.endsWith('_sandbox')
? `${k.slice(0, -'_sandbox'.length)} (sandbox)`
: k}
<div class="flex flex-col gap-2 pb-6">
<div class="flex flex-row items-center gap-2">
<IconComponent size={24} width="24" height="24" class="shrink-0" />
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-xs font-semibold text-emphasis">{k}</label>
<label class="text-xs font-semibold text-emphasis">{headerLabel}</label>
<Button
variant="subtle"
destructive
@@ -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,
@@ -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
}
@@ -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(
+5 -18
View File
@@ -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<AIProvider, AIProviderDetails> = {
},
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') {
+2 -3
View File
@@ -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'
}
@@ -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<typeof computeAssetNodes> = {
@@ -274,8 +276,8 @@
<Tooltip class={'pr-1 flex items-center justify-center'}>
<AlertTriangle size={16} class="text-orange-500" />
{#snippet text()}
Could not find resource
{/snippet}
Could not find resource
{/snippet}
</Tooltip>
{:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !$userStore?.operator}
<div transition:slide={{ axis: 'x', duration: 100 }}>
@@ -291,29 +293,27 @@
{/if}
</div>
{#snippet text()}
{#if usageCount !== undefined}
Used in {pluralize(usageCount, 'step')}<br />
{/if}
<a
href={undefined}
class={twMerge(
'text-xs',
data.asset.kind === 'resource' ? 'text-accent cursor-pointer' : 'text-hint'
)}
onclick={() => {
if (data.asset.kind === 'resource')
flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path)
}}
>
{data.asset.path}
</a><br />
<span class="text-hint text-xs">
{formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })}</span
>
<AssetColumnBadges columns={assetColumns} disableTooltip />
{/snippet}
{#if usageCount !== undefined}
Used in {pluralize(usageCount, 'step')}<br />
{/if}
<a
href={undefined}
class={twMerge(
'text-xs',
data.asset.kind === 'resource' ? 'text-accent cursor-pointer' : 'text-hint'
)}
onclick={() => {
if (data.asset.kind === 'resource')
flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path)
}}
>
{data.asset.path}
</a><br />
<span class="text-hint text-xs">
{formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })}</span
>
<AssetColumnBadges columns={assetColumns} disableTooltip />
{/snippet}
</Tooltip>
{/snippet}
</NodeWrapper>
@@ -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)
}
})
})
@@ -353,7 +353,7 @@
{#if showWorkspaceOverrideEditor}
<SettingCard label="AI Providers">
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
{#each Object.entries(AI_PROVIDERS) as [provider, details]}
{#each Object.entries(AI_PROVIDERS) as [provider, details] (provider)}
<div class="flex flex-col">
<div class="flex flex-row gap-2">
<Toggle
@@ -493,7 +493,8 @@
disabled={autocompleteModels.length == 0}
options={{
right: 'Enable code completion',
rightTooltip: 'We currently only support Mistral Codestral models for code completion.'
rightTooltip:
'We currently support Mistral Codestral and DeepSeek FIM models for code completion.'
}}
/>
</SettingCard>
+2 -1
View File
@@ -28,7 +28,8 @@ const config = {
base: process.env.VITE_BASE_URL ?? ''
},
alias: {
'$system_prompts': '../system_prompts/auto-generated'
$system_prompts: '../system_prompts/auto-generated',
$oauth_connect_registry: '../backend/oauth_connect.json'
}
},
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.711.0"
wmill = ">=1.712.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.711.0
version: 1.712.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.711.0'
ModuleVersion = '1.712.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -1 +1 @@
1.711.0
1.712.0