14 KiB
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 (
ai.rs,google.rs,bedrock.rs) with its own request building for Google/Bedrock, plusAIRequestConfig::prepare_requestfor auth/URL handling - windmill-worker — agent execution (
ai/module) withQueryBuildertrait, SSE parsers, provider implementations
The goal: a single windmill-ai crate with all AI provider logic. Both the API proxy and worker agent use QueryBuilder for every provider — no more duplicate logic.
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 the Next PR Small
The first merged PR established the crate boundary; it did not yet remove the duplicated API-vs-worker provider paths. The remaining work should stay split by dependency risk, not by the final desired module layout.
Do not jump directly from the current state to provider moves, proxy unification, and credential unification in one PR. The riskiest part is the API proxy because it 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.
Pull the shared plumbing forward before moving provider implementations:
- Move tiny shared utilities first, including
AI_HTTP_HEADERS,extract_text_content, andshould_use_structured_output_tool. - Move SSE parsers next, using the existing
StreamEventSinkabstraction, and update callers to import fromwindmill_aidirectly. - Leave provider implementations, image upload/download handling, API proxy changes, and credential unification out of that PR.
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.
Next Phase PR: Shared Plumbing Only
Goal: make windmill-ai own the provider-independent helper code that later provider moves will need, without changing API proxy behavior or agent request behavior.
Suggested PR title: refactor(ai): move shared SSE plumbing into windmill-ai.
Scope:
- Add
windmill-ai/src/utils.rs. - Move the duplicated
AI_HTTP_HEADERSparsing intowindmill_ai::utilswith identical parsing behavior. - Move
extract_text_contentandshould_use_structured_output_toolfromwindmill-worker/src/ai/utils.rstowindmill_ai::utils. - Move
windmill-worker/src/ai/sse.rstowindmill-ai/src/sse.rs. - Delete
windmill-worker/src/ai/sse.rsand update callers to import parser types fromwindmill_ai::sse. - Update callers of moved utility functions to import from
windmill_ai::utilsdirectly. - Add the minimal new
windmill-aidependencies required bysse.rs(eventsource-stream,tokio-stream) and avoid adding worker/queue dependencies.
Out of scope:
- Do not move provider implementations.
- Do not move
image_handler. - Do not change
QueryBuildermethod signatures. - Do not add
build_proxy_request. - Do not change API proxy routing, request preparation, credential resolution, audit logging, cache behavior, or Bedrock/Google special cases.
- Do not remove
windmill-api/src/google.rs,windmill-api/src/bedrock.rs, orAIRequestConfig::prepare_request.
Implementation checklist:
- Add
utils.rstowindmill-aiand export it fromlib.rs. - Move
AI_HTTP_HEADERSexactly once, then updatewindmill-api/src/ai.rsandwindmill-worker/src/ai_executor.rsto import it. - Move the two provider-independent helper functions into
windmill_ai::utils; leave worker-specific flow/MCP/conversation utilities inwindmill-worker/src/ai/utils.rs. - Move
sse.rsintowindmill-ai, change imports fromcrate::ai::{query_builder, types}tocrate::{query_builder, types}, and keep behavior unchanged. - Remove worker
ai/sse.rsand update provider imports to usewindmill_ai::ssedirectly. - Run focused grep checks for duplicate
AI_HTTP_HEADERS, old local helper definitions, and accidentalwindmill_queue/worker dependencies fromwindmill-ai. - Validate with
cargo check -p windmill-ai,cargo check -p windmill-worker, andcargo check -p windmill-api. Forbedrockbuilds, also check the existing bedrock feature path.
Review expectations:
- The diff should be mostly moved code and import updates.
- The behavior should be byte-for-byte equivalent where practical.
- Tests are only needed if helper behavior changes. For a pure move, existing backend checks plus manual AI streaming verification are enough.
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 typesai_providers.rs—AIProviderenum,AIPlatform, base URLs,ProviderConfigai_google.rs— Gemini types and OpenAI↔Gemini conversionai_bedrock.rs— Bedrock SDK wrapper (feature-gated onbedrock)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 typesTokenUsage— token usage trackingOutputType,SchemaType,AdditionalProperties— output configurationOpenAPISchema— tool parameter schema (depends onwindmill-parser::Typ)Tool,Message,ResponseFormat,JsonSchemaFormat— agent typesStreamingEvent— SSE event enumAIAgentArgs,AIAgentArgsRaw,AIAgentResult— agent job argsMemory— agent memory enumS3ObjectWithType— S3 image typeMcpToolSourcestub (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:
BuildRequestArgsstructParsedResponseenumQueryBuildertrait (with all existing methods)
New StreamEventSink trait in windmill-ai:
#[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:
SSEParsertraitOpenAISSEParser,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—AnthropicQueryBuilderopenai.rs—OpenAIQueryBuildergoogle_ai.rs—GoogleAIQueryBuilderbedrock.rs—BedrockQueryBuilder(feature-gated)other.rs—OtherQueryBuilder(Mistral, DeepSeek, Groq, TogetherAI, CustomAI)openrouter.rs—OpenRouterQueryBuildermod.rswithcreate_query_builderfactory
Move utility functions providers depend on:
should_use_structured_output_tool(fromutils.rs)extract_text_content(fromutils.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 neededprepare_messages_for_api— no signature change neededupload_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 proxy support to QueryBuilder — API uses QueryBuilder for all providers
This is the key unification step. Add a new method to the QueryBuilder trait:
/// 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:
pub struct ProxyBuildArgs<'a> {
pub method: 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:
pub struct ProxyRequest {
pub url: String,
pub body: Vec<u8>,
pub headers: Vec<(String, String)>,
}
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: Convert OpenAI format → Gemini format (using existing
ai_googlefunctions). Replaceswindmill-api/src/google.rs. - Bedrock: Convert OpenAI format → Bedrock SDK calls. Replaces
windmill-api/src/bedrock.rs.
Refactor API proxy (windmill-api/src/ai.rs):
- Parse provider from headers, resolve credentials →
ProviderCredentials - Create
QueryBuilderviacreate_query_builder - Call
query_builder.build_proxy_request(&proxy_args)→ProxyRequest - Send the request, return response with SSE keepalive injection
Remove from windmill-api:
AIRequestConfig::prepare_request— replaced byQueryBuilder::build_proxy_requestgoogle.rs— replaced byGoogleAIQueryBuilder::build_proxy_requestbedrock.rs— replaced byBedrockQueryBuilder::build_proxy_requesttransform_anthropic_for_vertex— moved toAnthropicQueryBuildersupports_native_fim,transform_fim_to_chat_completions— moved to windmill-ai
Keep in API:
AIRequestConfig::newcredential resolution until it is refactored to produceProviderCredentials- HTTP routes, audit logging, request caching
inject_keepalives,is_sse_responsehelpersAIConfig,ExpiringAIRequestConfigcaching types
Step 9: Unify credential resolution
Merge AIRequestConfig (API-side) and ProviderWithResource (worker-side) into a single type in windmill-ai.
Both currently carry: api_key, base_url, region, platform, custom_headers, AWS credentials. The API's AIRequestConfig::new resolves credentials from DB (workspace/instance settings). The worker's ProviderWithResource gets credentials from the flow module definition.
Create windmill_ai::ProviderCredentials that both can produce:
pub struct ProviderCredentials {
pub provider: AIProvider,
pub api_key: Option<String>,
pub base_url: 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 custom_headers: HashMap<String, String>,
pub organization_id: Option<String>,
pub enable_1m_context: bool,
}
The create_query_builder factory takes &ProviderCredentials instead of &ProviderWithResource.
Final Crate Structure
windmill-ai/src/
├── lib.rs # re-exports, AI_HTTP_HEADERS
├── 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 # ProviderCredentials, TokenUsage, Tool, OpenAPISchema, etc.
├── query_builder.rs # QueryBuilder trait, BuildRequestArgs, ParsedResponse, ProxyRequest, 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 + build_proxy_request
├── bedrock.rs # build_request + build_proxy_request (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.