feat: add instance-level AI settings (#8453)

* feat: add instance-level AI settings with workspace fallback

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

* feat: add AI step to onboarding setup wizard

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

* fix: thread workspace prop through resource editor and disable chat offset

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

* Revert "fix: thread workspace prop through resource editor and disable chat offset"

This reverts commit 9fea9cc0c239f6432d1fef1487c45e74ab752e21.

* fix: set workspace store and disable chat offset during AI setup step

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

* fix: thread workspace and disableChatOffset props through resource editors

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

* fix: populate workspace and user stores for AI step path component

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

* fix: initialize AI clients for test key during onboarding

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

* refactor: extract AI config state into InstanceAISettings component

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

* refactor: move AI config state ownership into AISettings component

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

* Persist instance AI settings before navigation

* Reload effective workspace AI state after save

* Scope AI key tests to the rendered workspace

* Add post-create AI onboarding for new workspaces

* Unify instance AI settings header

* Fix instance AI drawer offset on workspace selection

* Add instance AI fallback settings behavior

* Update sqlx metadata

* Update sqlx metadata

* Clarify active instance AI in workspace settings

* Refresh workspace AI state after instance AI save

* Declare instance AI summary in API schema

* Normalize empty instance AI config handling

* Clean up workspace AI settings UI

* Unify AI config provider checks

* Split AI settings metadata from effective config

* Propagate instance AI cache invalidation across servers

* Fix AI settings dirty state tracking

* Update sqlx metadata

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-03-24 20:18:36 +01:00
committed by GitHub
parent a26a2e8092
commit db5e03610d
30 changed files with 1844 additions and 758 deletions
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM global_settings WHERE name = 'ai_config'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a"
}
+7 -2
View File
@@ -36,9 +36,10 @@ use windmill_common::ee_oss::{
use windmill_common::{
agent_workers::AgentConfig,
ai_cache::bump_instance_ai_config_revision,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING,
BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING,
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
@@ -1813,6 +1814,10 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload app workspaced route setting");
}
}
AI_CONFIG_SETTING => {
tracing::info!("AI config setting changed, bumping instance AI cache revision");
bump_instance_ai_config_revision();
}
OTEL_SETTING => {
tracing::info!("OTEL setting changed, restarting");
send_delayed_killpill(tx, 4, "OTEL setting change").await;
@@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.status(), 200);
// --- allowed_domain_auto_invite ---
let resp = authed(client().get(format!(
"{global_base}/allowed_domain_auto_invite"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<bool>().await?;
@@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
resp.json::<Vec<String>>().await?;
// --- get_dependents (empty, no dependencies exist) ---
let resp = authed(client().get(format!(
"{base}/get_dependents/u/test-user/nonexistent"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let dependents = resp.json::<Vec<serde_json::Value>>().await?;
assert!(dependents.is_empty());
@@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// --- edit_large_file_storage_config ---
let resp = authed(client().post(format!(
"{base}/edit_large_file_storage_config"
)))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
let resp = authed(client().post(format!("{base}/edit_large_file_storage_config")))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
@@ -532,9 +526,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.unwrap();
let invites = resp.json::<Vec<serde_json::Value>>().await?;
assert!(
invites
.iter()
.any(|i| i["email"] == "invited@example.com"),
invites.iter().any(|i| i["email"] == "invited@example.com"),
"invite not found: {:?}",
invites
);
@@ -549,12 +541,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"delete_invite: {}",
resp.text().await?
);
assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?);
// ===== Critical alerts (EE-gated, returns 404 in OSS) =====
@@ -624,12 +611,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"create_fork: {}",
resp.text().await?
);
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
// verify fork exists
let resp = authed(client().post(format!("{global_base}/exists")))
@@ -702,13 +684,122 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.json::<bool>().await?, false);
// --- create_workspace_require_superadmin ---
let resp = authed(client().get(format!(
"{global_base}/create_workspace_require_superadmin"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().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 instance_ai_config = json!({
"providers": {
"openai": {
"resource_path": "u/test-user/openai_instance",
"models": ["gpt-4o-mini"]
}
}
});
let workspace_ai_config = json!({
"providers": {
"anthropic": {
"resource_path": "u/test-user/anthropic_workspace",
"models": ["claude-3-5-haiku-latest"]
}
}
});
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(instance_ai_config)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], true);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["models"][0],
"gpt-4o-mini"
);
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
.bind(workspace_ai_config)
.bind("test-workspace")
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], false);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_info_ignores_empty_instance_ai_row(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(json!({}))
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_info")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert!(settings["providers"].is_null());
Ok(())
}
+15 -1
View File
@@ -38,11 +38,12 @@ use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalEr
#[cfg(all(feature = "private", feature = "enterprise"))]
use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings};
use windmill_common::{
ai_cache::bump_instance_ai_config_revision,
email_oss::send_email_plain_text,
error::{self, JsonResult, Result},
get_database_url,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
WS_BASE_URL_SETTING,
@@ -284,6 +285,7 @@ pub async fn set_global_setting_internal(
key: String,
value: serde_json::Value,
) -> error::Result<()> {
let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING;
let value = if key == "retention_period_secs" {
instance_config::clamp_retention_period(value)
} else {
@@ -325,6 +327,10 @@ pub async fn set_global_setting_internal(
}
};
if should_bump_instance_ai_revision {
bump_instance_ai_config_revision();
}
Ok(())
}
@@ -471,6 +477,10 @@ async fn set_instance_config(
let current_map = current.global_settings.to_settings_map();
let settings_diff =
instance_config::diff_global_settings(&current_map, &desired_map, ApplyMode::Merge);
let ai_config_changed = settings_diff
.upserts
.iter()
.any(|(key, _)| key == AI_CONFIG_SETTING);
for (key, value) in &settings_diff.upserts {
run_setting_pre_write_hook(&db, key, value).await?;
@@ -479,6 +489,10 @@ async fn set_instance_config(
instance_config::apply_settings_diff(&db, &settings_diff)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
if ai_config_changed {
bump_instance_ai_config_revision();
}
}
if !desired.worker_configs.is_empty() {
@@ -82,6 +82,10 @@ pub fn workspaced_service() -> Router {
.route("/get_dependents/*imported_path", get(get_dependents))
.route("/get_dependents_amounts", post(get_dependents_amounts))
.route("/get_settings", get(get_settings))
.route(
"/get_copilot_settings_state",
get(get_copilot_settings_state),
)
.route("/get_deploy_to", get(get_deploy_to))
.route("/edit_slack_command", post(edit_slack_command))
.route(
@@ -257,6 +261,35 @@ pub struct WorkspaceSettings {
pub public_app_execution_limit_per_minute: Option<i32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct CopilotSettingsState {
pub has_instance_ai_config: bool,
pub uses_instance_ai_config: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_ai_summary: Option<InstanceAISummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAIProviderSummary {
pub provider: String,
pub models: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAIModelSummary {
pub provider: String,
pub model: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAISummary {
pub providers: Vec<InstanceAIProviderSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<InstanceAIModelSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_completion_model: Option<InstanceAIModelSummary>,
}
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
// pub enum WorkspaceKeyKind {
@@ -608,15 +641,106 @@ async fn get_settings(
.await
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
tx.commit().await?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
if !authed.is_admin {
settings.slack_oauth_client_secret = None;
}
Ok(Json(settings))
}
async fn get_copilot_settings_state(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<CopilotSettingsState> {
let mut tx = user_db.begin(&authed).await?;
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
let instance_ai_config: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
tx.commit().await?;
Ok(Json(build_copilot_settings_state(
has_ai_providers(workspace_ai_config.as_ref()),
instance_ai_config.as_ref(),
)))
}
pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool {
config
.and_then(|value| value.get("providers"))
.and_then(|providers| providers.as_object())
.map(|providers| !providers.is_empty())
.unwrap_or(false)
}
pub fn build_copilot_settings_state(
has_workspace_ai_config: bool,
instance_ai_config: Option<&serde_json::Value>,
) -> CopilotSettingsState {
let has_instance_ai_config = has_ai_providers(instance_ai_config);
CopilotSettingsState {
has_instance_ai_config,
uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config,
instance_ai_summary: build_instance_ai_summary(instance_ai_config),
}
}
pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option<InstanceAISummary> {
let config = config?;
if !has_ai_providers(Some(config)) {
return None;
}
let providers = config.get("providers")?.as_object()?;
let mut provider_summaries = providers
.iter()
.map(|(provider, provider_config)| InstanceAIProviderSummary {
provider: provider.clone(),
models: provider_config
.get("models")
.and_then(|models| models.as_array())
.map(|models| {
models
.iter()
.filter_map(|model| model.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
})
.collect::<Vec<_>>();
provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider));
Some(InstanceAISummary {
providers: provider_summaries,
default_model: extract_instance_ai_model_summary(config, "default_model"),
code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"),
})
}
fn extract_instance_ai_model_summary(
config: &serde_json::Value,
key: &str,
) -> Option<InstanceAIModelSummary> {
let model_config = config.get(key)?.as_object()?;
Some(InstanceAIModelSummary {
provider: model_config.get("provider")?.as_str()?.to_owned(),
model: model_config.get("model")?.as_str()?.to_owned(),
})
}
#[derive(Serialize)]
struct DeployTo {
deploy_to: Option<String>,
+69 -2
View File
@@ -3191,9 +3191,49 @@ paths:
"200":
description: status
content:
text/plain:
application/json:
schema:
type: string
type: object
properties:
effective_ai_config:
$ref: "#/components/schemas/AIConfig"
has_instance_ai_config:
type: boolean
uses_instance_ai_config:
type: boolean
instance_ai_summary:
$ref: "#/components/schemas/InstanceAISummary"
required:
- effective_ai_config
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/get_copilot_settings_state:
get:
summary: get copilot settings state
operationId: getCopilotSettingsState
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
application/json:
schema:
type: object
properties:
has_instance_ai_config:
type: boolean
uses_instance_ai_config:
type: boolean
instance_ai_summary:
$ref: "#/components/schemas/InstanceAISummary"
required:
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/get_copilot_info:
get:
@@ -18792,6 +18832,33 @@ components:
minimum: 1
maximum: 2000000
InstanceAIProviderSummary:
type: object
properties:
provider:
$ref: "#/components/schemas/AIProvider"
models:
type: array
items:
type: string
required:
- provider
- models
InstanceAISummary:
type: object
properties:
providers:
type: array
items:
$ref: "#/components/schemas/InstanceAIProviderSummary"
default_model:
$ref: "#/components/schemas/AIProviderModel"
code_completion_model:
$ref: "#/components/schemas/AIProviderModel"
required:
- providers
Alert:
type: object
properties:
+169 -44
View File
@@ -16,6 +16,7 @@ use serde_json::{json, value::RawValue};
use std::collections::HashMap;
use std::time::Duration;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::ai_cache::current_instance_ai_config_revision;
use windmill_common::ai_providers::{
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
};
@@ -127,6 +128,10 @@ lazy_static::lazy_static! {
};
}
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id);
}
#[derive(Deserialize, Debug)]
struct AIOAuthResource {
client_id: String,
@@ -373,8 +378,7 @@ impl AIRequestConfig {
let is_azure = provider.is_azure_openai(base_url);
let is_anthropic = matches!(provider, AIProvider::Anthropic);
let is_anthropic_vertex =
is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
@@ -483,18 +487,27 @@ impl AIRequestConfig {
pub struct ExpiringAIRequestConfig {
config: AIRequestConfig,
expires_at: std::time::Instant,
instance_ai_config_revision: Option<u64>,
}
impl ExpiringAIRequestConfig {
fn new(config: AIRequestConfig) -> Self {
Self { config, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60) }
fn new(config: AIRequestConfig, instance_ai_config_revision: Option<u64>) -> Self {
Self {
config,
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
instance_ai_config_revision,
}
}
fn is_expired(&self) -> bool {
self.expires_at < std::time::Instant::now()
|| self
.instance_ai_config_revision
.is_some_and(|revision| revision != current_instance_ai_config_revision())
}
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct AIConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<AIProvider, ProviderConfig>>,
@@ -508,6 +521,14 @@ pub struct AIConfig {
pub max_tokens_per_model: Option<HashMap<String, i32>>,
}
impl AIConfig {
pub fn has_providers(&self) -> bool {
self.providers
.as_ref()
.is_some_and(|providers| !providers.is_empty())
}
}
/// Anthropic API version for Google Vertex AI
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
@@ -762,47 +783,76 @@ async fn proxy(
request_cache.config
}
_ => {
let (resource_path, save_to_cache) = if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false)
} else {
let ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false, w_id.clone(), None)
} else {
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if ai_config.is_none() {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
let ws_has_config = workspace_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.is_some_and(|config| config.has_providers());
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config.unwrap())
.map_err(|e| Error::BadRequest(e.to_string()))?;
if ws_has_config {
(workspace_ai_config.unwrap(), w_id.clone(), None)
} else {
let instance_config = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'ai_config'"
)
.fetch_optional(&db)
.await?;
let provider_config = ai_config
.providers
.as_mut()
.map(|providers| providers.remove(&provider))
.flatten()
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
match instance_config {
Some(config) => (
config,
"admins".to_string(),
Some(current_instance_ai_config_revision()),
),
None => {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
}
}
};
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
.map_err(|e| Error::BadRequest(e.to_string()))?;
(provider_config.resource_path, true)
};
let provider_config = ai_config
.providers
.as_mut()
.and_then(|providers| providers.remove(&provider))
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
let resource= sqlx::query_scalar!(
"SELECT value as \"value: sqlx::types::Json<Box<RawValue>>\" FROM resource WHERE path = $1 AND workspace_id = $2",
&resource_path,
&w_id
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
(
provider_config.resource_path,
true,
resource_workspace,
instance_ai_config_revision,
)
};
let resource = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
@@ -811,11 +861,15 @@ async fn proxy(
let resource = serde_json::from_str::<AIResource>(resource.0.get())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let request_config = AIRequestConfig::new(&provider, &db, &w_id, resource).await?;
let request_config =
AIRequestConfig::new(&provider, &db, &resource_workspace, resource).await?;
if save_to_cache {
AI_REQUEST_CACHE.insert(
(w_id.clone(), provider.clone()),
ExpiringAIRequestConfig::new(request_config.clone()),
ExpiringAIRequestConfig::new(
request_config.clone(),
instance_ai_config_revision,
),
);
}
request_config
@@ -858,9 +912,7 @@ async fn proxy(
"chat/completions" => {
crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await
}
"models" => {
crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await
}
"models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await,
_ => Err(Error::BadRequest(format!(
"Unsupported Google AI path: {}",
ai_path
@@ -1005,3 +1057,76 @@ async fn proxy(
};
Ok((status_code, headers, body))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{LazyLock, Mutex};
use windmill_common::ai_cache::bump_instance_ai_config_revision;
use windmill_common::ai_providers::AIPlatform;
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
fn sample_request_config() -> AIRequestConfig {
AIRequestConfig {
base_url: "https://example.com".to_string(),
api_key: None,
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(),
}
}
#[test]
fn invalidates_all_cached_providers_for_workspace() {
let _guard = TEST_LOCK.lock().unwrap();
AI_REQUEST_CACHE.clear();
AI_REQUEST_CACHE.insert(
("workspace-a".to_string(), AIProvider::OpenAI),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
AI_REQUEST_CACHE.insert(
("workspace-a".to_string(), AIProvider::Anthropic),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
AI_REQUEST_CACHE.insert(
("workspace-b".to_string(), AIProvider::OpenAI),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
invalidate_ai_request_cache_for_workspace("workspace-a");
assert!(AI_REQUEST_CACHE
.get(&("workspace-a".to_string(), AIProvider::OpenAI))
.is_none());
assert!(AI_REQUEST_CACHE
.get(&("workspace-a".to_string(), AIProvider::Anthropic))
.is_none());
assert!(AI_REQUEST_CACHE
.get(&("workspace-b".to_string(), AIProvider::OpenAI))
.is_some());
}
#[test]
fn instance_backed_cache_entries_expire_when_revision_changes() {
let _guard = TEST_LOCK.lock().unwrap();
AI_REQUEST_CACHE.clear();
let cached = ExpiringAIRequestConfig::new(
sample_request_config(),
Some(current_instance_ai_config_revision()),
);
assert!(!cached.is_expired());
bump_instance_ai_config_revision();
assert!(cached.is_expired());
}
}
+51 -22
View File
@@ -8,8 +8,9 @@
// Re-export everything from windmill-api-workspaces
pub use windmill_api_workspaces::workspaces::*;
use windmill_api_workspaces::workspaces::{build_copilot_settings_state, InstanceAISummary};
use crate::ai::{AIConfig, AI_REQUEST_CACHE};
use crate::ai::{invalidate_ai_request_cache_for_workspace, AIConfig};
use crate::db::ApiAuthed;
use crate::teams_oss::{
connect_teams, edit_teams_command, run_teams_message_test_job,
@@ -24,7 +25,7 @@ use axum::{
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
error::{Error, JsonResult, Result},
error::{Error, JsonResult},
utils::require_admin,
DB,
};
@@ -34,6 +35,9 @@ use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use axum::extract::Query;
#[cfg(feature = "enterprise")]
use serde::Deserialize;
use serde::Serialize;
#[cfg(feature = "enterprise")]
use windmill_common::error::Result;
#[cfg(feature = "enterprise")]
use windmill_common::utils::require_admin_or_devops;
@@ -83,7 +87,7 @@ async fn edit_copilot_config(
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(ai_config): Json<AIConfig>,
) -> Result<String> {
) -> JsonResult<EditCopilotConfigResponse> {
require_admin(is_admin, &username)?;
if let Some(ref custom_prompts) = ai_config.custom_prompts {
@@ -109,11 +113,7 @@ async fn edit_copilot_config(
.execute(&mut *tx)
.await?;
if let Some(ref providers) = ai_config.providers {
for provider in providers.keys() {
AI_REQUEST_CACHE.remove(&(w_id.clone(), provider.clone()));
}
}
invalidate_ai_request_cache_for_workspace(&w_id);
audit_log(
&mut *tx,
@@ -139,37 +139,66 @@ async fn edit_copilot_config(
)
.await?;
Ok(format!("Edit copilot config for workspace {}", &w_id))
let workspace_has_config = ai_config.has_providers();
let instance_ai_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
.await?;
let settings_state =
build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref());
let effective_ai_config = if workspace_has_config {
ai_config
} else if let Some(instance_ai_config) = instance_ai_config {
serde_json::from_value::<AIConfig>(instance_ai_config).unwrap_or_default()
} else {
AIConfig::default()
};
Ok(Json(EditCopilotConfigResponse {
effective_ai_config,
has_instance_ai_config: settings_state.has_instance_ai_config,
uses_instance_ai_config: settings_state.uses_instance_ai_config,
instance_ai_summary: settings_state.instance_ai_summary,
}))
}
#[derive(Serialize)]
struct EditCopilotConfigResponse {
effective_ai_config: AIConfig,
has_instance_ai_config: bool,
uses_instance_ai_config: bool,
#[serde(skip_serializing_if = "Option::is_none")]
instance_ai_summary: Option<InstanceAISummary>,
}
async fn get_copilot_info(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<AIConfig> {
let mut tx = db.begin().await?;
let copilot_info = sqlx::query_scalar!(
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config as \"ai_config: sqlx::types::Json<AIConfig>\" FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&mut *tx)
.fetch_one(&db)
.await
.map_err(|e| {
Error::internal_err(format!(
"getting ai config: {e:#}"
))
})?;
tx.commit().await?;
if let Some(sqlx::types::Json(copilot_info)) = copilot_info {
Ok(Json(copilot_info))
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
Ok(Json(workspace_ai_config.0))
} else if let Some(instance_config) =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
.await?
{
Ok(Json(
serde_json::from_value::<AIConfig>(instance_config).unwrap_or_default(),
))
} else {
Ok(Json(AIConfig {
providers: None,
default_model: None,
code_completion_model: None,
custom_prompts: None,
max_tokens_per_model: None,
}))
Ok(Json(AIConfig::default()))
}
}
+11
View File
@@ -0,0 +1,11 @@
use std::sync::atomic::{AtomicU64, Ordering};
static INSTANCE_AI_CONFIG_REVISION: AtomicU64 = AtomicU64::new(0);
pub fn current_instance_ai_config_revision() -> u64 {
INSTANCE_AI_CONFIG_REVISION.load(Ordering::SeqCst)
}
pub fn bump_instance_ai_config_revision() -> u64 {
INSTANCE_AI_CONFIG_REVISION.fetch_add(1, Ordering::SeqCst) + 1
}
@@ -4,6 +4,7 @@ pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces";
pub const BASE_URL_SETTING: &str = "base_url";
pub const WS_BASE_URL_SETTING: &str = "ws_base_url";
pub const OAUTH_SETTING: &str = "oauths";
pub const AI_CONFIG_SETTING: &str = "ai_config";
pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs";
pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days";
pub const MONITOR_LOGS_ON_OBJECT_STORE_SETTING: &str = "monitor_logs_on_s3";
@@ -351,6 +351,13 @@ pub struct GlobalSettings {
std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub ai_config: Option<serde_json::Value>,
/// Catch-all for settings not yet covered by typed fields.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
+1
View File
@@ -29,6 +29,7 @@ use sqlx::{Acquire, Postgres};
pub mod agent_workers;
#[cfg(feature = "bedrock")]
pub mod ai_bedrock;
pub mod ai_cache;
pub mod ai_google;
pub mod ai_providers;
pub mod ai_types;
@@ -10,9 +10,11 @@
interface Props {
expressOAuthSetup?: boolean
workspace?: string
disableChatOffset?: boolean
}
let { expressOAuthSetup = false }: Props = $props()
let { expressOAuthSetup = false, workspace = undefined, disableChatOffset = false }: Props = $props()
let drawer: Drawer | undefined = $state()
let resourceType = $state('')
@@ -50,6 +52,7 @@
dispatch('close')
}}
size="800px"
{disableChatOffset}
>
<DrawerContent
title="Add a resource"
@@ -68,6 +71,7 @@
on:close={drawer?.closeDrawer}
on:refresh
express={expressOAuthSetup}
{workspace}
/>
{#snippet actions()}
<div class="flex gap-1">
@@ -42,6 +42,7 @@
disabled?: boolean
manual?: boolean
express?: boolean
workspace?: string
}
let {
@@ -50,9 +51,12 @@
isGoogleSignin = $bindable(false),
disabled = $bindable(false),
manual = $bindable(true),
express = false
express = false,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let isValid = $state(true)
const nativeLanguagesCategory = [
@@ -214,7 +218,7 @@
return
}
const availableRts = await ResourceService.listResourceTypeNames({
workspace: $workspaceStore!
workspace: effectiveWorkspace
})
connectsManual = availableRts
@@ -316,7 +320,7 @@
async function getResourceTypeInfo() {
resourceTypeInfo = await ResourceService.getResourceType({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: resourceType
})
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
@@ -419,7 +423,7 @@
// Check if variable paths already exist
if (!manual || linkedSecrets.length <= 1) {
const exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path
})
if (exists) {
@@ -429,7 +433,7 @@
for (const secretField of linkedSecrets) {
const varPath = `${path}_${secretField}`
const exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: varPath
})
if (exists) {
@@ -440,7 +444,7 @@
}
}
let exists = await ResourceService.existsResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path
})
@@ -478,7 +482,7 @@
account = Number(
await OauthService.createAccount({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: accountData
})
)
@@ -492,7 +496,7 @@
if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path,
value: value,
@@ -513,7 +517,7 @@
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path,
value: v,
@@ -532,7 +536,7 @@
const varPath = `${path}_${secretField}`
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path: varPath,
value: v,
@@ -549,7 +553,7 @@
}
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
resource_type: resourceType,
path,
@@ -33,6 +33,7 @@
hidePath?: boolean
onChange?: (args: { path: string; args: Record<string, any>; description: string }) => void
defaultValues?: Record<string, any> | undefined
workspace?: string | undefined
}
let {
@@ -41,9 +42,12 @@
path = $bindable(''),
hidePath = false,
onChange,
defaultValues = undefined
defaultValues = undefined,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let isValid = $state(true)
let jsonError = $state('')
let can_write = $state(true)
@@ -68,13 +72,13 @@
let rawCode: string | undefined = $state(undefined)
async function initEdit() {
resourceToEdit = await ResourceService.getResource({ workspace: $workspaceStore!, path })
resourceToEdit = await ResourceService.getResource({ workspace: effectiveWorkspace, path })
description = resourceToEdit!.description ?? ''
resource_type = resourceToEdit!.resource_type
args = resourceToEdit?.value ?? ({} as any)
loadResourceType()
can_write =
resourceToEdit.workspace_id == $workspaceStore &&
resourceToEdit.workspace_id == effectiveWorkspace &&
canWrite(path, resourceToEdit.extra_perms ?? {}, $userStore)
linkedVars = Object.entries(args)
.filter(([_, v]) => typeof v == 'string' && v == `$var:${initialPath}`)
@@ -92,12 +96,12 @@
export async function editResource(): Promise<void> {
if (resourceToEdit) {
await ResourceService.updateResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: resourceToEdit.path,
requestBody: { path, value: args, description }
})
if (resourceToEdit.resource_type === 'json_schema') {
clearJsonSchemaResourceCache(resourceToEdit.path, $workspaceStore!)
clearJsonSchemaResourceCache(resourceToEdit.path, effectiveWorkspace)
}
sendUserToast(`Updated resource at ${path}`)
dispatch('refresh', path)
@@ -108,7 +112,7 @@
export async function createResource(): Promise<void> {
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: { path, value: args, description, resource_type: resource_type! }
})
sendUserToast(`Updated resource at ${path}`)
@@ -119,7 +123,7 @@
if (resource_type) {
try {
const resourceType = await ResourceService.getResourceType({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: resource_type
})
@@ -5,6 +5,11 @@
import { Loader2, Save } from 'lucide-svelte'
let {
workspace = undefined,
disableChatOffset = false
}: { workspace?: string; disableChatOffset?: boolean } = $props()
let drawer: Drawer | undefined = $state()
let canSave = $state(true)
let resource_type: string | undefined = $state(undefined)
@@ -34,7 +39,7 @@
let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit')
</script>
<Drawer bind:this={drawer} size="800px">
<Drawer bind:this={drawer} size="800px" {disableChatOffset}>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
on:close={drawer?.closeDrawer}
@@ -46,6 +51,7 @@
{path}
{resource_type}
{defaultValues}
{workspace}
on:refresh
bind:this={resourceEditor}
bind:canSave
@@ -29,6 +29,8 @@
onClear?: () => void
excludedValues?: string[]
datatableAsPgResource?: boolean
workspace?: string | undefined
disableChatOffset?: boolean
}
let {
@@ -47,9 +49,13 @@
class: className = '',
onClear = undefined,
excludedValues = undefined,
datatableAsPgResource = false
datatableAsPgResource = false,
workspace = undefined,
disableChatOffset = false
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
if (initialValue && value == undefined) {
value = initialValue
}
@@ -104,7 +110,7 @@
const resources = await Promise.all(
resourceTypesToQuery.map((rt) =>
ResourceService.listResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
resourceType: rt
})
)
@@ -122,7 +128,7 @@
if (datatableAsPgResource && resourceType === 'postgresql') {
try {
const datatables = await WorkspaceService.listDataTables({
workspace: $workspaceStore!
workspace: effectiveWorkspace
})
for (const dt of datatables) {
nc.push({
@@ -155,7 +161,7 @@
let previousResourceType = untrack(() => resourceType)
$effect(() => {
$workspaceStore && resourceType
effectiveWorkspace && resourceType
untrack(() => {
if (previousResourceType != resourceType) {
previousResourceType = resourceType
@@ -167,7 +173,7 @@
$effect(() => {
excludedValues
if ($workspaceStore && resourceType && !disabled) {
if (effectiveWorkspace && resourceType && !disabled) {
untrack(() => loadResources(resourceType))
}
})
@@ -186,9 +192,13 @@
}}
bind:this={appConnect}
{expressOAuthSetup}
{workspace}
{disableChatOffset}
/>
<ResourceEditorDrawer
bind:this={resourceEditor}
{workspace}
{disableChatOffset}
on:refresh={async (e) => {
await loadResources(resourceType)
if (e.detail) {
@@ -146,6 +146,7 @@
bind:this={innerComponent}
closeDrawer={handleClose}
showHeaderInfo={false}
{disableChatOffset}
bind:yamlMode
bind:hasUnsavedChanges
bind:hasAnyInvalid
@@ -39,12 +39,14 @@
import TextInput from './text_input/TextInput.svelte'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte'
import InstanceAISettings from './instanceSettings/InstanceAISettings.svelte'
let filter = $state('')
let {
closeDrawer,
showHeaderInfo = true,
disableChatOffset = false,
yamlMode = $bindable(false),
hasUnsavedChanges = $bindable(false),
hasAnyInvalid = $bindable(false)
@@ -234,7 +236,9 @@
<div class="flex-1 min-w-0 h-full">
<div class="h-full overflow-auto bg-surface">
<div class="h-fit px-8 py-4">
{#if tab === 'users' && !yamlMode}
{#if tab === 'ai' && !yamlMode}
<InstanceAISettings {disableChatOffset} />
{:else if tab === 'users' && !yamlMode}
<div class="h-full">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
@@ -7,6 +7,7 @@
interface Props {
disabled?: boolean
apiKey?: string | undefined
workspace?: string | undefined
resourcePath?: string | undefined
aiProvider: AIProvider
model: string
@@ -15,6 +16,7 @@
let {
disabled = false,
apiKey = undefined,
workspace = undefined,
resourcePath = undefined,
aiProvider,
model
@@ -38,6 +40,7 @@
await testKey({
apiKey,
workspace,
resourcePath,
messages: [
{
@@ -5,10 +5,14 @@ import type {
ChatCompletionCreateParams
} from 'openai/resources/index.mjs'
import type { ResponseErrorEvent } from 'openai/resources/responses/responses.mjs'
import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib'
import {
createOpenAIProxyClient,
getAiProxyBaseURL,
getProviderAndCompletionConfig,
workspaceAIClients
} from '../lib'
import { processToolCall, type Tool, type ToolCallbacks } from './shared'
import type { ResponseStream } from 'openai/lib/responses/ResponseStream.mjs'
import { OpenAPI } from '$lib/gen'
import type { AIProviderModel } from '$lib/gen'
// Conversion utilities for Responses API
@@ -354,6 +358,7 @@ export async function getNonStreamingOpenAIResponsesCompletion(
abortController: AbortController,
testOptions?: {
apiKey?: string
workspace?: string
resourcePath?: string
forceModelProvider: AIProviderModel
}
@@ -390,15 +395,10 @@ export async function getNonStreamingOpenAIResponsesCompletion(
}
const openaiClient = testOptions?.apiKey
? new OpenAI({
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getOpenaiClient()
? createOpenAIProxyClient(getAiProxyBaseURL())
: testOptions?.workspace
? workspaceAIClients.createOpenaiClient(testOptions.workspace)
: workspaceAIClients.getOpenaiClient()
const response = await openaiClient.responses.create(
{
+54 -39
View File
@@ -67,7 +67,14 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
},
googleai: {
label: 'Google AI',
defaultModels: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite', 'gemini-3-flash', 'gemini-3.1-pro', 'gemini-3.1-flash-lite']
defaultModels: [
'gemini-2.5-flash',
'gemini-2.5-pro',
'gemini-2.5-flash-lite',
'gemini-3-flash',
'gemini-3.1-pro',
'gemini-3.1-flash-lite'
]
},
groq: {
label: 'Groq',
@@ -364,38 +371,46 @@ export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCr
aws_bedrock: DEFAULT_COMPLETION_CONFIG
} as const
export function getAiProxyBaseURL(workspace?: string): string {
return workspace
? `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
: `${location.origin}${OpenAPI.BASE}/ai/proxy`
}
export function createOpenAIProxyClient(baseURL: string): OpenAI {
return new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
}
export function createAnthropicProxyClient(baseURL: string): Anthropic {
return new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
}
class WorkspacedAIClients {
private openaiClient: OpenAI | undefined
private anthropicClient: Anthropic | undefined
init(workspace: string) {
this.initOpenai(workspace)
this.initAnthropic(workspace)
this.openaiClient = this.createOpenaiClient(workspace)
this.anthropicClient = this.createAnthropicClient(workspace)
}
private getBaseURL(workspace: string) {
return `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
createOpenaiClient(workspace: string): OpenAI {
return createOpenAIProxyClient(getAiProxyBaseURL(workspace))
}
private initOpenai(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.openaiClient = new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
}
private initAnthropic(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.anthropicClient = new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
createAnthropicClient(workspace: string): Anthropic {
return createAnthropicProxyClient(getAiProxyBaseURL(workspace))
}
getOpenaiClient() {
@@ -417,6 +432,7 @@ export const workspaceAIClients = new WorkspacedAIClients()
export async function testKey({
apiKey,
workspace,
resourcePath,
model,
abortController,
@@ -424,6 +440,7 @@ export async function testKey({
aiProvider
}: {
apiKey?: string
workspace?: string
resourcePath?: string
model: string | undefined
messages: ChatCompletionMessageParam[]
@@ -443,6 +460,7 @@ export async function testKey({
if (aiProvider === 'anthropic') {
await testAnthropicKey({
apiKey,
workspace,
resourcePath,
model: modelToTest,
abortController,
@@ -453,6 +471,7 @@ export async function testKey({
await getNonStreamingCompletion(messages, abortController, {
apiKey,
workspace,
resourcePath,
forceModelProvider: {
model: modelToTest,
@@ -463,12 +482,14 @@ export async function testKey({
async function testAnthropicKey({
apiKey,
workspace,
resourcePath,
model,
abortController,
messages
}: {
apiKey?: string
workspace?: string
resourcePath?: string
model: string
abortController: AbortController
@@ -489,12 +510,10 @@ async function testAnthropicKey({
}
const anthropicClient = apiKey
? new Anthropic({
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getAnthropicClient()
? createAnthropicProxyClient(getAiProxyBaseURL())
: workspace
? workspaceAIClients.createAnthropicClient(workspace)
: workspaceAIClients.getAnthropicClient()
await anthropicClient.messages.create(
{
@@ -719,6 +738,7 @@ export async function getNonStreamingCompletion(
testOptions?: {
apiKey?: string // testing API KEY using the global ai proxy
resourcePath?: string // testing resource path passed as a header to the backend proxy
workspace?: string // use a specific workspace proxy when testing a workspace resource
forceModelProvider: AIProviderModel
}
) {
@@ -768,15 +788,10 @@ export async function getNonStreamingCompletion(
}
}
const openaiClient = testOptions?.apiKey
? new OpenAI({
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getOpenaiClient()
? createOpenAIProxyClient(getAiProxyBaseURL())
: testOptions?.workspace
? workspaceAIClients.createOpenaiClient(testOptions.workspace)
: workspaceAIClients.getOpenaiClient()
const completion = await openaiClient.chat.completions.create(config, fetchOptions)
response = completion.choices?.[0]?.message.content || ''
@@ -822,6 +822,17 @@ export const instanceSettingsNavigationGroups = [
}
]
},
{
title: 'AI',
items: [
{
id: 'ai',
label: 'AI',
aiId: 'instance-settings-ai',
aiDescription: 'Instance AI settings (providers, models, prompts)'
}
]
},
{
title: 'Advanced',
items: [
@@ -863,6 +874,7 @@ export const instanceSettingsNavigationGroups = [
export const tabToCategoryMap: Record<string, string> = {
general: 'Core',
ai: 'AI',
sso: 'Auth/OAuth/SAML',
oauth: 'Auth/OAuth/SAML',
scim_saml: 'Auth/OAuth/SAML',
@@ -897,6 +909,7 @@ export const setupNavigationGroups = instanceSettingsNavigationGroups
export const categoryToTabMap: Record<string, string> = {
Core: 'general',
AI: 'ai',
SMTP: 'smtp',
'Auth/OAuth/SAML': 'sso',
Registries: 'registries',
@@ -0,0 +1,149 @@
<script lang="ts">
import { JobService, SettingService, WorkspaceService, type AIConfig } from '$lib/gen'
import { setCopilotInfo } from '$lib/aiStore'
import { workspaceStore, userStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
import { workspaceAIClients } from '../copilot/lib'
import AISettings from '../workspaceSettings/AISettings.svelte'
import { Alert, Button } from '../common'
interface Props {
hasUnsavedChanges?: boolean
disableChatOffset?: boolean
showHubSync?: boolean
}
let {
hasUnsavedChanges = $bindable(false),
disableChatOffset = false,
showHubSync = false
}: Props = $props()
let initialConfig: AIConfig | undefined = $state(undefined)
let loaded = $state(false)
let aiSettings: AISettings | undefined = $state(undefined)
async function loadConfig() {
try {
initialConfig =
((await SettingService.getGlobal({ key: 'ai_config' })) as AIConfig | undefined) ?? {}
loaded = true
} catch (e) {
console.error('Failed to load instance AI config', e)
sendUserToast('Failed to load instance AI config', true)
}
}
async function handleCustomSave(config: AIConfig) {
const hasProviders = Object.keys(config.providers ?? {}).length > 0
await SettingService.setGlobal({
key: 'ai_config',
requestBody: { value: hasProviders ? config : null }
})
if ($workspaceStore) {
try {
const effectiveConfig = await WorkspaceService.getCopilotInfo({
workspace: $workspaceStore
})
setCopilotInfo(effectiveConfig)
} catch (e) {
console.error('Failed to refresh workspace AI state after instance save', e)
}
}
sendUserToast('Instance AI settings saved')
}
export async function persistBeforeExit(): Promise<boolean> {
return (await aiSettings?.saveIfDirtyAndValid()) ?? true
}
// Ensure stores are set (this page may bypass the (logged) layout)
async function ensureStores() {
if (!$workspaceStore) {
$workspaceStore = 'admins'
}
if (!$userStore) {
$userStore = await getUserExt($workspaceStore)
}
workspaceAIClients.init($workspaceStore)
}
ensureStores()
loadConfig()
// --- Hub sync ---
let hubSyncStatus: 'idle' | 'loading' | 'success' | 'error' = $state('idle')
let hubSyncMessage = $state('')
async function syncFromHub() {
hubSyncStatus = 'loading'
hubSyncMessage = ''
try {
await JobService.runWaitResultScriptByPath({
workspace: 'admins',
path: 'u/admin/hub_sync',
requestBody: {}
})
hubSyncStatus = 'success'
hubSyncMessage = 'Resource types synced from hub successfully'
} catch (e: any) {
hubSyncMessage =
e?.body?.error?.message ||
e?.body?.message ||
(typeof e?.body === 'string' ? e.body : null) ||
e?.message ||
'Failed to sync from hub'
hubSyncStatus = 'error'
}
}
</script>
{#if loaded}
{#if showHubSync}
<div
class="p-3 border rounded-md bg-surface-secondary mb-4 mt-4 flex items-center justify-between gap-4"
>
<div>
<p class="text-xs font-medium text-secondary">Resource types</p>
<p class="text-2xs text-tertiary mt-0.5">
AI providers require their resource types. Sync from the Hub if they are missing.
</p>
</div>
<Button
variant="default"
unifiedSize="sm"
loading={hubSyncStatus === 'loading'}
onClick={syncFromHub}
>
Sync from hub
</Button>
</div>
{#if hubSyncStatus === 'success'}
<div class="mb-4">
<Alert type="success" title="Resource types synced">
{hubSyncMessage}
</Alert>
</div>
{:else if hubSyncStatus === 'error'}
<div class="mb-4">
<Alert type="error" title="Sync failed">
{hubSyncMessage}
</Alert>
</div>
{/if}
{/if}
<AISettings
bind:this={aiSettings}
bind:hasUnsavedChanges
{initialConfig}
workspace="admins"
{disableChatOffset}
title="Windmill AI"
description="Windmill AI integrates with your favorite AI providers and models. Set your AI settings at the instance level to be able to use them on all your workspaces. Workspace-level settings can override these."
link="https://www.windmill.dev/docs/core_concepts/ai_generation"
promptScope="instance"
customSave={handleCustomSave}
/>
{/if}
@@ -13,7 +13,7 @@
onSave?: () => void
onReset: () => void
hasChanges: boolean
isWorkspaceSettings?: boolean
scope?: 'user' | 'workspace' | 'instance'
}
let {
@@ -22,7 +22,7 @@
onSave,
onReset,
hasChanges,
isWorkspaceSettings = false
scope = 'user'
}: Props = $props()
const placeholders: Record<AIMode, string> = {
@@ -63,9 +63,12 @@
<div class="flex flex-col gap-6 h-full px-1 w-full">
<div class="grow min-h-0 overflow-y-auto" style="scrollbar-gutter: stable;">
<div class="text-xs text-secondary mb-6">
{#if isWorkspaceSettings}
{#if scope === 'workspace'}
Customize the system prompts for each AI mode. These prompts apply to all workspace
members.
{:else if scope === 'instance'}
Customize the system prompts for each AI mode. These prompts apply to workspaces using
instance AI defaults.
{:else}
Customize the system prompts for each AI mode. These prompts are stored locally in your
browser and apply in addition to workspace-level prompts.
@@ -1,5 +1,12 @@
<script lang="ts">
import { WorkspaceService, type AIConfig, type AIProvider } from '$lib/gen'
import {
ResourceService,
WorkspaceService,
type AIConfig,
type AIProvider,
type GetCopilotSettingsStateResponse,
type InstanceAISummary
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { AI_PROVIDERS, fetchAvailableModels } from '../copilot/lib'
@@ -19,32 +26,139 @@
import { setCopilotInfo } from '$lib/aiStore'
import AIPromptsModal from '../settings/AIPromptsModal.svelte'
import { Settings } from 'lucide-svelte'
import { untrack } from 'svelte'
import { slide } from 'svelte/transition'
import SettingsFooter from './SettingsFooter.svelte'
import InstanceFallbackSettings from './InstanceFallbackSettings.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
let {
aiProviders = $bindable(),
codeCompletionModel = $bindable(),
defaultModel = $bindable(),
customPrompts = $bindable(),
maxTokensPerModel = $bindable(),
usingOpenaiClientCredentialsOauth = $bindable(),
onSave,
onDiscard,
hasUnsavedChanges = false
initialConfig = undefined,
hasUnsavedChanges = $bindable(false),
workspace = undefined,
disableChatOffset = false,
hasInstanceAiConfig = false,
usesInstanceAiConfig = false,
instanceAiSummary = undefined,
customSave = undefined,
onSave = undefined,
title = 'Windmill AI',
description = 'Windmill AI integrates with your favorite AI providers and models.',
link = 'https://www.windmill.dev/docs/core_concepts/ai_generation',
promptScope = 'workspace'
}: {
aiProviders: Exclude<AIConfig['providers'], undefined>
codeCompletionModel: string | undefined
defaultModel: string | undefined
customPrompts: Record<string, string>
maxTokensPerModel: Record<string, number>
usingOpenaiClientCredentialsOauth: boolean
onSave?: () => void
onDiscard?: () => void
initialConfig?: AIConfig | undefined
hasUnsavedChanges?: boolean
workspace?: string | undefined
disableChatOffset?: boolean
hasInstanceAiConfig?: boolean
usesInstanceAiConfig?: boolean
instanceAiSummary?: InstanceAISummary
customSave?: (config: AIConfig) => Promise<void>
onSave?: (info?: GetCopilotSettingsStateResponse) => void | Promise<void>
title?: string
description?: string
link?: string
promptScope?: 'workspace' | 'instance'
} = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
// --- Internal state ---
let aiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let codeCompletionModel: string | undefined = $state(undefined)
let defaultModel: string | undefined = $state(undefined)
let customPrompts: Record<string, string> = $state({})
let maxTokensPerModel: Record<string, number> = $state({})
let usingOpenaiClientCredentialsOauth = $state(false)
let workspaceOverrideEditorOpened = $state(false)
// --- Initial state for dirty tracking ---
let initialAiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let initialCodeCompletionModel: string | undefined = $state(undefined)
let initialDefaultModel: string | undefined = $state(undefined)
let initialCustomPrompts: Record<string, string> = $state({})
let initialMaxTokensPerModel: Record<string, number> = $state({})
let initialPrompts: Record<string, string> = $state({})
let lastLoadedConfigKey = $state<string | undefined>(undefined)
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v))
}
function applyConfig(config: AIConfig | undefined) {
aiProviders = clone(config?.providers ?? {})
defaultModel = config?.default_model?.model
codeCompletionModel = config?.code_completion_model?.model
customPrompts = clone(config?.custom_prompts ?? {})
maxTokensPerModel = clone(config?.max_tokens_per_model ?? {})
for (const mode of ['edit', 'fix', 'gen']) {
if (!(mode in customPrompts)) {
customPrompts[mode] = ''
}
}
}
function storeInitialState() {
initialAiProviders = clone(aiProviders)
initialDefaultModel = defaultModel
initialCodeCompletionModel = codeCompletionModel
initialCustomPrompts = clone(customPrompts)
initialMaxTokensPerModel = clone(maxTokensPerModel)
initialPrompts = clone(customPrompts)
}
export function loadFromConfig(config: AIConfig | undefined) {
applyConfig(config)
storeInitialState()
}
export function discard() {
aiProviders = clone(initialAiProviders)
defaultModel = initialDefaultModel
codeCompletionModel = initialCodeCompletionModel
customPrompts = clone(initialCustomPrompts)
maxTokensPerModel = clone(initialMaxTokensPerModel)
}
$effect(() => {
const configKey = JSON.stringify(initialConfig ?? {})
if (configKey === lastLoadedConfigKey) {
return
}
lastLoadedConfigKey = configKey
untrack(() => {
loadFromConfig(initialConfig)
})
})
// Check if openai_client_credentials_oauth resource type exists
async function loadOpenaiOauthFlag() {
try {
usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({
workspace: effectiveWorkspace,
path: 'openai_client_credentials_oauth'
})
} catch {
usingOpenaiClientCredentialsOauth = false
}
}
loadOpenaiOauthFlag()
// --- Dirty tracking ---
let dirty = $derived(
JSON.stringify(aiProviders) !== JSON.stringify(initialAiProviders) ||
defaultModel !== initialDefaultModel ||
codeCompletionModel !== initialCodeCompletionModel ||
JSON.stringify(customPrompts) !== JSON.stringify(initialCustomPrompts) ||
JSON.stringify(maxTokensPerModel) !== JSON.stringify(initialMaxTokensPerModel)
)
$effect(() => {
hasUnsavedChanges = dirty
})
// --- Model fetching ---
let fetchedAiModels = $state(false)
let availableAiModels = $state(
Object.fromEntries(
@@ -53,7 +167,6 @@
)
let modalOpen = $state(false)
let initialPrompts = $state($state.snapshot(customPrompts))
let hasPromptsChanges = $derived(
Array.from(new Set([...Object.keys(customPrompts), ...Object.keys(initialPrompts)])).some(
(key) => {
@@ -66,6 +179,14 @@
let promptCount = $derived(
Object.values(customPrompts).filter((p) => p?.trim().length > 0).length
)
let promptDescription = $derived(
promptScope === 'instance'
? 'Customize AI behavior with instance-level system prompts. These apply when a workspace uses instance AI defaults.'
: 'Customize AI behavior with workspace-level system prompts. These apply to all workspace members.'
)
let showWorkspaceOverrideEditor = $derived(
!usesInstanceAiConfig || Object.keys(aiProviders).length > 0 || workspaceOverrideEditorOpened
)
let selectedAiModels = $derived(Object.values(aiProviders).flatMap((p) => p.models))
let modelProviderMap = $derived(
@@ -91,7 +212,7 @@
try {
const models = await fetchAvailableModels(
aiProviders[provider].resource_path,
$workspaceStore!,
effectiveWorkspace,
provider as AIProvider
)
availableAiModels[provider] = models
@@ -109,44 +230,74 @@
sendUserToast('Reset to last saved state')
}
async function editCopilotConfig(): Promise<void> {
if (Object.keys(aiProviders ?? {}).length > 0) {
const code_completion_model =
codeCompletionModel && modelProviderMap[codeCompletionModel]
? { model: codeCompletionModel, provider: modelProviderMap[codeCompletionModel] }
: undefined
const default_model =
defaultModel && modelProviderMap[defaultModel]
? { model: defaultModel, provider: modelProviderMap[defaultModel] }
: undefined
// Convert customPrompts to include only non-empty prompts
const custom_prompts: Record<string, string> = Object.entries(customPrompts)
.filter(([_, prompt]) => prompt.trim().length > 0)
.reduce((acc, [mode, prompt]) => ({ ...acc, [mode]: prompt }), {})
function buildConfig(): AIConfig {
const code_completion_model =
codeCompletionModel && modelProviderMap[codeCompletionModel]
? { model: codeCompletionModel, provider: modelProviderMap[codeCompletionModel] }
: undefined
const default_model =
defaultModel && modelProviderMap[defaultModel]
? { model: defaultModel, provider: modelProviderMap[defaultModel] }
: undefined
const custom_prompts: Record<string, string> = Object.entries(customPrompts)
.filter(([_, prompt]) => prompt.trim().length > 0)
.reduce((acc, [mode, prompt]) => ({ ...acc, [mode]: prompt }), {})
const config: AIConfig = {
providers: aiProviders,
code_completion_model,
default_model,
custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined,
max_tokens_per_model:
Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined
}
await WorkspaceService.editCopilotConfig({
workspace: $workspaceStore!,
return Object.keys(aiProviders ?? {}).length > 0
? {
providers: aiProviders,
code_completion_model,
default_model,
custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined,
max_tokens_per_model:
Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined
}
: {}
}
function isSaveDisabled(): boolean {
return (
!Object.values(aiProviders).every((p) => p.resource_path) ||
(codeCompletionModel != undefined && codeCompletionModel.length === 0) ||
(Object.keys(aiProviders).length > 0 && !defaultModel)
)
}
export async function saveIfDirtyAndValid(): Promise<boolean> {
if (!dirty) {
return true
}
if (isSaveDisabled()) {
sendUserToast('Complete AI settings before leaving this page', true)
return false
}
await editCopilotConfig()
return true
}
async function editCopilotConfig(): Promise<void> {
const config = buildConfig()
let settingsState: GetCopilotSettingsStateResponse | undefined
if (customSave) {
await customSave(config)
} else {
const response = await WorkspaceService.editCopilotConfig({
workspace: effectiveWorkspace,
requestBody: config
})
setCopilotInfo(config)
} else {
await WorkspaceService.editCopilotConfig({
workspace: $workspaceStore!,
requestBody: {}
})
setCopilotInfo({})
setCopilotInfo(response.effective_ai_config)
settingsState = {
has_instance_ai_config: response.has_instance_ai_config,
uses_instance_ai_config: response.uses_instance_ai_config,
instance_ai_summary: response.instance_ai_summary
}
sendUserToast('AI settings updated')
}
sendUserToast(`AI settings updated`)
initialPrompts = { ...customPrompts } // Update initial prompts after successful save
onSave?.()
storeInitialState()
await onSave?.(settingsState)
}
async function onAiProviderChange(provider: AIProvider) {
@@ -154,7 +305,7 @@
try {
const models = await fetchAvailableModels(
aiProviders[provider].resource_path,
$workspaceStore!,
effectiveWorkspace,
provider as AIProvider
)
availableAiModels[provider] = models
@@ -176,197 +327,214 @@
const autocompleteModels = $derived(selectedAiModels.filter(supportsAutocomplete))
</script>
<SettingsPageHeader
title="Windmill AI"
description="Windmill AI integrates with your favorite AI providers and models."
link="https://www.windmill.dev/docs/core_concepts/ai_generation"
/>
<SettingsPageHeader {title} {description} {link} />
<div class="flex flex-col gap-6 mt-4 pb-8">
<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]}
<div class="flex flex-col">
<div class="flex flex-row gap-2">
<Toggle
options={{
right: details.label
}}
checked={!!aiProviders[provider]}
on:change={(e) => {
if (e.detail) {
aiProviders = {
...aiProviders,
[provider]: {
resource_path: '',
models:
availableAiModels[provider].length > 0
? [availableAiModels[provider][0]]
: []
}
}
if (availableAiModels[provider].length > 0 && !defaultModel) {
defaultModel = availableAiModels[provider][0]
}
} else {
aiProviders = Object.fromEntries(
Object.entries(aiProviders).filter(([key]) => key !== provider)
)
if (defaultModel) {
const currentDefaultModel = Object.values(aiProviders).find(
(p) => defaultModel && p.models.includes(defaultModel)
)
if (!currentDefaultModel) {
defaultModel = undefined
}
}
if (codeCompletionModel) {
const currentCodeCompletionModel = Object.values(aiProviders).find(
(p) => codeCompletionModel && p.models.includes(codeCompletionModel)
)
if (!currentCodeCompletionModel) {
codeCompletionModel = undefined
}
}
}
}}
/>
{#if provider === 'anthropic'}
<Badge color="blue">
Recommended
<Tooltip>
Anthropic models handle tool calls better than other providers, which makes them a
better choice for AI chat.
</Tooltip>
</Badge>
{/if}
</div>
{#if aiProviders[provider]}
<div
class="mb-4 flex flex-col gap-6 border p-4 rounded-md mt-2"
transition:slide|local={{ duration: 150 }}
>
<Label label="Resource">
<div class="flex flex-row gap-1">
<ResourcePicker
selectFirst
resourceType={provider === 'openai' && usingOpenaiClientCredentialsOauth
? 'openai_client_credentials_oauth'
: provider}
initialValue={aiProviders[provider].resource_path}
bind:value={
() => aiProviders[provider].resource_path || undefined,
(v) => {
aiProviders[provider].resource_path = v ?? ''
onAiProviderChange(provider as AIProvider)
{#if usesInstanceAiConfig}
<div
class="p-3 border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 rounded-md text-xs text-secondary"
>
Instance-level AI settings are currently active. Configure workspace-specific settings below
to override them.
</div>
<InstanceFallbackSettings
{instanceAiSummary}
{showWorkspaceOverrideEditor}
onToggleOverride={() => (workspaceOverrideEditorOpened = !workspaceOverrideEditorOpened)}
/>
{:else if hasInstanceAiConfig && Object.keys(aiProviders).length > 0}
<div
class="p-3 border border-surface-hover bg-surface-secondary rounded-md text-xs text-secondary"
>
Workspace AI settings override instance defaults. Remove workspace settings to use instance
defaults.
</div>
{/if}
{#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]}
<div class="flex flex-col">
<div class="flex flex-row gap-2">
<Toggle
options={{
right: details.label
}}
checked={!!aiProviders[provider]}
on:change={(e) => {
if (e.detail) {
aiProviders = {
...aiProviders,
[provider]: {
resource_path: '',
models:
availableAiModels[provider].length > 0
? [availableAiModels[provider][0]]
: []
}
}
/>
<TestAiKey
aiProvider={provider as AIProvider}
resourcePath={aiProviders[provider].resource_path}
model={aiProviders[provider].models[0]}
/>
</div>
</Label>
<Label label="Enabled models">
<MultiSelect
items={safeSelectItems([
...availableAiModels[provider],
...aiProviders[provider].models
])}
bind:value={aiProviders[provider].models}
placeholder="Select models"
onCreateItem={(item) =>
(aiProviders[provider].models = [...aiProviders[provider].models, item])}
/>
<p class="text-2xs text-hint">
If you don't see the model you want, you can type it manually in the selector.
</p>
</Label>
if (availableAiModels[provider].length > 0 && !defaultModel) {
defaultModel = availableAiModels[provider][0]
}
} else {
aiProviders = Object.fromEntries(
Object.entries(aiProviders).filter(([key]) => key !== provider)
)
if (defaultModel) {
const currentDefaultModel = Object.values(aiProviders).find(
(p) => defaultModel && p.models.includes(defaultModel)
)
if (!currentDefaultModel) {
defaultModel = undefined
}
}
if (codeCompletionModel) {
const currentCodeCompletionModel = Object.values(aiProviders).find(
(p) => codeCompletionModel && p.models.includes(codeCompletionModel)
)
if (!currentCodeCompletionModel) {
codeCompletionModel = undefined
}
}
}
}}
/>
{#if provider === 'anthropic'}
<Badge color="blue">
Recommended
<Tooltip>
Anthropic models handle tool calls better than other providers, which makes them
a better choice for AI chat.
</Tooltip>
</Badge>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</SettingCard>
<SettingCard label="Default chat model">
{#key Object.keys(aiProviders).length}
<Select
items={safeSelectItems(selectedAiModels)}
bind:value={defaultModel}
disabled={false}
placeholder="Select a default model"
size="sm"
class="max-w-lg"
/>
{/key}
</SettingCard>
{#if aiProviders[provider]}
<div
class="mb-4 flex flex-col gap-6 border p-4 rounded-md mt-2"
transition:slide|local={{ duration: 150 }}
>
<Label label="Resource">
<div class="flex flex-row gap-1">
<ResourcePicker
selectFirst
{workspace}
{disableChatOffset}
resourceType={provider === 'openai' && usingOpenaiClientCredentialsOauth
? 'openai_client_credentials_oauth'
: provider}
initialValue={aiProviders[provider].resource_path}
bind:value={
() => aiProviders[provider].resource_path || undefined,
(v) => {
aiProviders[provider].resource_path = v ?? ''
onAiProviderChange(provider as AIProvider)
}
}
/>
<TestAiKey
aiProvider={provider as AIProvider}
workspace={effectiveWorkspace}
resourcePath={aiProviders[provider].resource_path}
model={aiProviders[provider].models[0]}
/>
</div>
</Label>
<!-- Code completion group for animation purposes -->
<div>
<SettingCard label="Code completion">
<Toggle
on:change={(e) => {
if (e.detail) {
codeCompletionModel = autocompleteModels[0] ?? ''
} else {
codeCompletionModel = undefined
}
}}
checked={codeCompletionModel != undefined}
disabled={autocompleteModels.length == 0}
options={{
right: 'Enable code completion',
rightTooltip: 'We currently only support Mistral Codestral models for code completion.'
}}
/>
<Label label="Enabled models">
<MultiSelect
items={safeSelectItems([
...availableAiModels[provider],
...aiProviders[provider].models
])}
bind:value={aiProviders[provider].models}
placeholder="Select models"
onCreateItem={(item) =>
(aiProviders[provider].models = [...aiProviders[provider].models, item])}
/>
<p class="text-2xs text-hint">
If you don't see the model you want, you can type it manually in the selector.
</p>
</Label>
</div>
{/if}
</div>
{/each}
</div>
</SettingCard>
{#if codeCompletionModel != undefined}
<div transition:slide|local={{ duration: 150 }} class="mt-6">
<SettingCard label="Code completion model">
<Select
items={safeSelectItems(autocompleteModels)}
bind:value={codeCompletionModel}
disabled={false}
placeholder="Select a code completion model"
size="sm"
/>
</SettingCard>
</div>
{/if}
</div>
<SettingCard label="Default chat model">
{#key Object.keys(aiProviders).length}
<Select
items={safeSelectItems(selectedAiModels)}
bind:value={defaultModel}
disabled={false}
placeholder="Select a default model"
size="sm"
class="max-w-lg"
/>
{/key}
</SettingCard>
<ModelTokenLimits {aiProviders} bind:maxTokensPerModel />
<!-- Code completion group for animation purposes -->
<div>
<SettingCard label="Code completion">
<Toggle
on:change={(e) => {
if (e.detail) {
codeCompletionModel = autocompleteModels[0] ?? ''
} else {
codeCompletionModel = undefined
}
}}
checked={codeCompletionModel != undefined}
disabled={autocompleteModels.length == 0}
options={{
right: 'Enable code completion',
rightTooltip: 'We currently only support Mistral Codestral models for code completion.'
}}
/>
</SettingCard>
<SettingCard
label="Custom system prompts"
description="Customize AI behavior with workspace-level system prompts. These apply to all workspace
members."
>
<div class="flex items-center gap-2 pt-1">
<Button
onclick={() => (modalOpen = true)}
variant="default"
unifiedSize="sm"
startIcon={{ icon: Settings }}
disabled={Object.keys(aiProviders ?? {}).length === 0}
>
Configure AI prompts
</Button>
{#if promptCount > 0}
<span class="text-xs text-secondary">({promptCount} configured)</span>
{/if}
{#if hasPromptsChanges}
<Badge color="yellow">Unsaved changes</Badge>
{#if codeCompletionModel != undefined}
<div transition:slide|local={{ duration: 150 }} class="mt-6">
<SettingCard label="Code completion model">
<Select
items={safeSelectItems(autocompleteModels)}
bind:value={codeCompletionModel}
disabled={false}
placeholder="Select a code completion model"
size="sm"
/>
</SettingCard>
</div>
{/if}
</div>
</SettingCard>
<ModelTokenLimits {aiProviders} bind:maxTokensPerModel />
<SettingCard label="Custom system prompts" description={promptDescription}>
<div class="flex items-center gap-2 pt-1">
<Button
onclick={() => (modalOpen = true)}
variant="default"
unifiedSize="sm"
startIcon={{ icon: Settings }}
disabled={Object.keys(aiProviders ?? {}).length === 0}
>
Configure AI prompts
</Button>
{#if promptCount > 0}
<span class="text-xs text-secondary">({promptCount} configured)</span>
{/if}
{#if hasPromptsChanges}
<Badge color="yellow">Unsaved changes</Badge>
{/if}
</div>
</SettingCard>
{/if}
</div>
<AIPromptsModal
@@ -374,15 +542,15 @@
bind:customPrompts
onReset={resetPrompts}
hasChanges={hasPromptsChanges}
isWorkspaceSettings={true}
scope={promptScope}
/>
<SettingsFooter
{hasUnsavedChanges}
onSave={editCopilotConfig}
onDiscard={() => onDiscard?.()}
saveLabel="Save AI settings"
disabled={!Object.values(aiProviders).every((p) => p.resource_path) ||
(codeCompletionModel != undefined && codeCompletionModel.length === 0) ||
(Object.keys(aiProviders).length > 0 && !defaultModel)}
/>
{#if showWorkspaceOverrideEditor}
<SettingsFooter
hasUnsavedChanges={dirty}
onSave={editCopilotConfig}
onDiscard={discard}
saveLabel="Save AI settings"
disabled={isSaveDisabled()}
/>
{/if}
@@ -11,7 +11,8 @@
VariableService,
WorkspaceService,
type AIProvider,
type CompletedJob
type CompletedJob,
type GetCopilotInfoResponse
} from '$lib/gen'
import { validateUsername } from '$lib/utils'
import { logoutWithRedirect } from '$lib/logoutKit'
@@ -52,6 +53,10 @@
let aiKey = $state('')
let codeCompletionEnabled = $state(true)
let checking = $state(false)
let createLoading = $state(false)
let aiSetupLoading = $state(false)
let creationStep = $state<'details' | 'ai'>('details')
let createdWorkspaceId: string | undefined = $state(undefined)
let workspaceColor: string | undefined = $state(undefined)
let colorEnabled = $state(false)
@@ -85,6 +90,64 @@
let errorMsgs: string[] = $state([])
let failedSyncJobs: string[] = $state([])
function getErrorMessage(error: any): string {
return (
error?.body?.error?.message ||
error?.body?.message ||
(typeof error?.body === 'string' ? error.body : null) ||
error?.message ||
'Unknown error'
)
}
function hasEffectiveAi(copilotInfo: GetCopilotInfoResponse): boolean {
return Object.keys(copilotInfo.providers ?? {}).length > 0
}
async function finishWorkspaceSetup(workspaceId: string): Promise<void> {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(workspaceId)
goto(rd ?? '/')
}
async function getWorkspaceUsername(workspaceId: string): Promise<string> {
if (!automateUsernameCreation) {
return username
}
const user = await UserService.whoami({
workspace: workspaceId
})
return user.username
}
async function maybeShowAiSetupStep(workspaceId: string): Promise<void> {
try {
const copilotInfo = await WorkspaceService.getCopilotInfo({
workspace: workspaceId
})
if (hasEffectiveAi(copilotInfo)) {
await finishWorkspaceSetup(workspaceId)
return
}
} catch (error) {
console.error('Failed to check effective AI configuration for new workspace', error)
sendUserToast(
'Workspace created, but Windmill AI availability could not be verified. You can configure it later in Workspace settings.',
true
)
await finishWorkspaceSetup(workspaceId)
return
}
createdWorkspaceId = workspaceId
creationStep = 'ai'
aiKey = ''
codeCompletionEnabled = true
selected = 'openai'
}
async function fetchFailedSyncJobs(jobs: string[]): Promise<CompletedJob[]> {
let ret: CompletedJob[] = []
for (const job of jobs) {
@@ -188,20 +251,22 @@
forkCreationLoading = false
sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`)
await finishWorkspaceSetup(prefixed_id)
} else {
sendUserToast('No workspace selected, cannot fork non-existent workspace', true)
}
} else {
await createWorkspace()
createLoading = true
try {
const workspaceId = await createWorkspace()
await maybeShowAiSetupStep(workspaceId)
} finally {
createLoading = false
}
}
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(isFork ? prefixed_id : id)
goto(rd ?? '/')
}
async function createWorkspace(): Promise<void> {
async function createWorkspace(): Promise<string> {
await WorkspaceService.createWorkspace({
requestBody: {
id,
@@ -216,17 +281,23 @@
requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd }
})
}
if (aiKey != '') {
let actualUsername = username
if (automateUsernameCreation) {
const user = await UserService.whoami({
workspace: id
})
actualUsername = user.username
}
let path = `u/${actualUsername}/${selected}_windmill_codegen`
sendUserToast(`Created workspace id: ${id}`)
return id
}
async function saveWorkspaceAiSetup(): Promise<void> {
if (!createdWorkspaceId || !aiKey) {
return
}
aiSetupLoading = true
try {
const actualUsername = await getWorkspaceUsername(createdWorkspaceId)
const path = `u/${actualUsername}/${selected}_windmill_codegen`
await VariableService.createVariable({
workspace: id,
workspace: createdWorkspaceId,
requestBody: {
path,
value: aiKey,
@@ -235,7 +306,7 @@
}
})
await ResourceService.createResource({
workspace: id,
workspace: createdWorkspaceId,
requestBody: {
path,
value: {
@@ -245,40 +316,46 @@
}
})
await WorkspaceService.editCopilotConfig({
workspace: id,
requestBody: aiKey
? {
providers: {
[selected]: {
resource_path: path,
models: [AI_PROVIDERS[selected].defaultModels[0]]
}
},
default_model: {
model: AI_PROVIDERS[selected].defaultModels[0],
provider: selected
},
code_completion_model: codeCompletionEnabled
? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected }
: undefined
workspace: createdWorkspaceId,
requestBody: {
providers: {
[selected]: {
resource_path: path,
models: [AI_PROVIDERS[selected].defaultModels[0]]
}
: {}
},
default_model: {
model: AI_PROVIDERS[selected].defaultModels[0],
provider: selected
},
code_completion_model: codeCompletionEnabled
? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected }
: undefined
}
})
sendUserToast('Windmill AI configured')
await finishWorkspaceSetup(createdWorkspaceId)
} catch (error) {
sendUserToast(`Failed to configure Windmill AI: ${getErrorMessage(error)}`, true)
} finally {
aiSetupLoading = false
}
sendUserToast(`Created workspace id: ${id}`)
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(id)
goto(rd ?? '/')
}
function handleKeyUp(event: KeyboardEvent) {
function handleCreateKeyUp(event: KeyboardEvent) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
createWorkspace()
createOrForkWorkspace()
}
}
function handleAiKeyUp(event: KeyboardEvent) {
const key = event.key
if (key === 'Enter' && aiKey) {
event.preventDefault()
saveWorkspaceAiSetup()
}
}
@@ -329,6 +406,9 @@
let operatorOnly = $state(false)
let autoAdd = $state(true)
let selected: Exclude<AIProvider, 'customai'> = $state('openai')
let modalTitle = $derived(
isFork ? 'Fork Workspace' : creationStep === 'ai' ? 'Set up Windmill AI' : 'New Workspace'
)
run(() => {
id = name.toLowerCase().replace(/\s/gi, '-')
})
@@ -344,7 +424,7 @@
let domain = $derived($usersWorkspaceStore?.email.split('@')[1])
</script>
<CenteredModal title="{isFork ? 'Forking' : 'New'} Workspace" centerVertically={false}>
<CenteredModal title={modalTitle} centerVertically={false}>
<div class="flex flex-col gap-8">
{#if isFork}
<div class="flex flex-block gap-2">
@@ -410,88 +490,184 @@
{/if}
</Alert>
{/if}
<label class="flex flex-col gap-1">
{#if isFork}
<span class="text-xs font-semibold text-emphasis">Fork name</span>
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
{:else}
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
<span class="text-xs text-secondary">Displayable name</span>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
{#if isFork}
<span class="text-xs text-secondary"
>Slug to uniquely identify your fork (this will also set the branch name)</span
>
{:else}
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
{/if}
{#if isFork}
<PrefixedInput
prefix={WM_FORK_PREFIX}
type="text"
bind:value={id}
placeholder="example.com"
class={errorId != '' ? 'input-error' : ''}
/>
{:else}
<TextInput bind:value={id} error={errorId} />
{/if}
{#if errorId}
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
{/if}
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace color</span>
<span class="text-xs text-secondary"
>Color to identify the current workspace in the list of workspaces</span
>
<div class="flex items-center gap-4">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}
<div class="flex items-center gap-1 grow">
<input
class="grow min-w-10"
type="color"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<TextInput
class="w-24"
bind:value={workspaceColor}
inputProps={{ disabled: !colorEnabled }}
/>
<Button
on:click={generateRandomColor}
size="xs"
variant="default"
disabled={!colorEnabled}>Random</Button
>
</div>
{/if}
</div>
</label>
{#if !automateUsernameCreation}
{#if isFork || creationStep === 'details'}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Your username in that workspace</span>
<TextInput bind:value={username} inputProps={{ onkeyup: handleKeyUp }} error={errorUser} />
{#if errorUser}
<span class="text-red-500 text-2xs">{errorUser}</span>
{#if isFork}
<span class="text-xs font-semibold text-emphasis">Fork name</span>
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
{:else}
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
<span class="text-xs text-secondary">Displayable name</span>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
{#if isFork}
<span class="text-xs text-secondary"
>Slug to uniquely identify your fork (this will also set the branch name)</span
>
{:else}
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
{/if}
{#if isFork}
<PrefixedInput
prefix={WM_FORK_PREFIX}
type="text"
bind:value={id}
placeholder="example.com"
class={errorId != '' ? 'input-error' : ''}
/>
{:else}
<TextInput bind:value={id} error={errorId} />
{/if}
{#if errorId}
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
{/if}
</label>
{/if}
{#if !isFork}
<div class="block">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace color</span>
<span class="text-xs text-secondary"
>Color to identify the current workspace in the list of workspaces</span
>
<div class="flex items-center gap-4">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}
<div class="flex items-center gap-1 grow">
<input
class="grow min-w-10"
type="color"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<TextInput
class="w-24"
bind:value={workspaceColor}
inputProps={{ disabled: !colorEnabled }}
/>
<Button
on:click={generateRandomColor}
size="xs"
variant="default"
disabled={!colorEnabled}>Random</Button
>
</div>
{/if}
</div>
</label>
{#if !automateUsernameCreation}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Your username in that workspace</span>
<TextInput
bind:value={username}
inputProps={{ onkeyup: handleCreateKeyUp }}
error={errorUser}
/>
{#if errorUser}
<span class="text-red-500 text-2xs">{errorUser}</span>
{/if}
</label>
{/if}
{#if !isFork}
<div class="flex flex-col gap-1">
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
>{isCloudHosted()
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
>
<Toggle
id="auto-invite"
disabled={isCloudHosted() && !isDomainAllowed}
bind:checked={auto_invite}
/>
{#if isCloudHosted() && isDomainAllowed == false}
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
{/if}
{#if auto_invite}
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
<!-- svelte-ignore a11y_label_has_associated_control -->
{#if isCloudHosted()}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Mode</span>
<span class="text-xs text-secondary font-normal"
>Whether to invite or add users directly to the workspace.</span
>
<ToggleButtonGroup
selected={autoAdd ? 'add' : 'invite'}
on:selected={async (e) => {
autoAdd = e.detail === 'add'
}}
>
{#snippet children({ item })}
<ToggleButton value="invite" label="Auto-invite" {item} />
<ToggleButton value="add" label="Auto-add" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
{/if}
<label class="font-semibold flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Role</span>
<span class="text-xs text-secondary font-normal">Role of the auto-invited users</span>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={(e) => {
operatorOnly = e.detail == 'operator'
}}
>
{#snippet children({ item })}
<ToggleButton value="operator" label="Operator" {item} />
<ToggleButton value="developer" label="Developer" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
</div>
{/if}
</div>
{/if}
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
<Button
disabled={forkCreationLoading || createLoading}
variant="default"
size="sm"
href="{base}/user/workspaces">&leftarrow; Back to workspaces</Button
>
{#if !forkCreationLoading}
<Button
variant="accent"
loading={createLoading}
disabled={createLoading ||
checking ||
errorId != '' ||
!name ||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
!id}
on:click={createOrForkWorkspace}
>
{#if isFork}
Fork workspace
{:else}
Create workspace
{/if}
</Button>
{:else}
<Button variant="accent" disabled={true}>
<LoaderCircle class="animate-spin" /> Creating branch
</Button>
{/if}
</div>
{:else}
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="ai-key" class="flex flex-row gap-2">
<span class="text-xs font-semibold text-emphasis">
AI key for Windmill AI
Set up Windmill AI
<Tooltip>
Find out how it can help you <a
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
@@ -500,10 +676,21 @@
>
</Tooltip>
</span>
<span class="text-2xs text-secondary">(optional but recommended)</span>
</label>
<span class="text-xs text-secondary">
Windmill AI powers the chat, code generation, flow creation, and code completion. Set
it up now or configure it later in Workspace settings.
<a
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
Learn more
</a>
</span>
<ToggleButtonGroup bind:selected>
<ToggleButtonGroup bind:selected class="mt-4">
{#snippet children({ item })}
<ToggleButton value="openai" label="OpenAI" {item} />
<ToggleButton value="anthropic" label="Anthropic" {item} />
@@ -517,7 +704,7 @@
type="password"
autocomplete="new-password"
bind:value={aiKey}
onkeyup={handleKeyUp}
onkeyup={handleAiKeyUp}
/>
<TestAIKey
apiKey={aiKey}
@@ -529,7 +716,7 @@
</div>
{#if aiKey}
<div class="flex flex-col gap-2 mt-2">
<div class="flex flex-col gap-2">
<Toggle
disabled={!aiKey}
bind:checked={codeCompletionEnabled}
@@ -538,91 +725,25 @@
</div>
{/if}
</div>
<div class="flex flex-col gap-1">
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
>{isCloudHosted()
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
<Button
variant="default"
size="sm"
disabled={aiSetupLoading || !createdWorkspaceId}
on:click={() => createdWorkspaceId && finishWorkspaceSetup(createdWorkspaceId)}
>
<Toggle
id="auto-invite"
disabled={isCloudHosted() && !isDomainAllowed}
bind:checked={auto_invite}
/>
{#if isCloudHosted() && isDomainAllowed == false}
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
{/if}
{#if auto_invite}
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
<!-- svelte-ignore a11y_label_has_associated_control -->
{#if isCloudHosted()}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Mode</span>
<span class="text-xs text-secondary font-normal"
>Whether to invite or add users directly to the workspace.</span
>
<ToggleButtonGroup
selected={autoAdd ? 'add' : 'invite'}
on:selected={async (e) => {
autoAdd = e.detail === 'add'
}}
>
{#snippet children({ item })}
<ToggleButton value="invite" label="Auto-invite" {item} />
<ToggleButton value="add" label="Auto-add" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
{/if}
<label class="font-semibold flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Role</span>
<span class="text-xs text-secondary font-normal">Role of the auto-invited users</span>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={(e) => {
operatorOnly = e.detail == 'operator'
}}
>
{#snippet children({ item })}
<ToggleButton value="operator" label="Operator" {item} />
<ToggleButton value="developer" label="Developer" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
</div>
{/if}
</div>
{/if}
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
<Button
disabled={forkCreationLoading}
variant="default"
size="sm"
href="{base}/user/workspaces">&leftarrow; Back to workspaces</Button
>
{#if !forkCreationLoading}
Skip for now
</Button>
<Button
variant="accent"
disabled={checking ||
errorId != '' ||
!name ||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
!id}
on:click={createOrForkWorkspace}
loading={aiSetupLoading}
disabled={aiSetupLoading || !aiKey}
on:click={saveWorkspaceAiSetup}
>
{#if isFork}
Fork workspace
{:else}
Create workspace
{/if}
Set up AI
</Button>
{:else}
<Button variant="accent" disabled={true}>
<LoaderCircle class="animate-spin" /> Creating branch
</Button>
{/if}
</div>
</div>
{/if}
</div>
</CenteredModal>
@@ -0,0 +1,93 @@
<script lang="ts">
import { type AIProvider, type InstanceAISummary } from '$lib/gen'
import { AI_PROVIDERS } from '../copilot/lib'
import Badge from '../common/badge/Badge.svelte'
import Button from '../common/button/Button.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
interface Props {
instanceAiSummary?: InstanceAISummary
showWorkspaceOverrideEditor?: boolean
onToggleOverride: () => void
}
let {
instanceAiSummary = undefined,
showWorkspaceOverrideEditor = false,
onToggleOverride
}: Props = $props()
let sortedInstanceProviders = $derived(
[...(instanceAiSummary?.providers ?? [])].sort((left, right) =>
left.provider.localeCompare(right.provider)
)
)
function getProviderLabel(provider: AIProvider): string {
return AI_PROVIDERS[provider]?.label ?? provider
}
</script>
{#if instanceAiSummary}
<SettingCard label="Active instance AI">
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
<p class="text-xs text-secondary">
This workspace is currently using the instance AI defaults shown below.
</p>
<div class="flex flex-col gap-3">
{#each sortedInstanceProviders as providerSummary}
<div class="rounded-md border bg-surface p-3 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium">
{getProviderLabel(providerSummary.provider)}
</span>
<Badge color="blue">Instance</Badge>
</div>
<div class="flex flex-wrap gap-1">
{#each providerSummary.models as model}
<Badge color="gray">{model}</Badge>
{/each}
</div>
</div>
{/each}
</div>
{#if instanceAiSummary.default_model}
<div class="text-xs text-secondary">
Default chat model:
<span class="text-primary font-medium">{instanceAiSummary.default_model.model}</span>
<span class="text-tertiary">
({getProviderLabel(instanceAiSummary.default_model.provider)})
</span>
</div>
{/if}
{#if instanceAiSummary.code_completion_model}
<div class="text-xs text-secondary">
Code completion model:
<span class="text-primary font-medium">
{instanceAiSummary.code_completion_model.model}
</span>
<span class="text-tertiary">
({getProviderLabel(instanceAiSummary.code_completion_model.provider)})
</span>
</div>
{/if}
</div>
</SettingCard>
{/if}
<SettingCard label="Workspace override">
<div class="flex flex-col gap-3 p-4 rounded-md border bg-surface-tertiary">
<p class="text-xs text-secondary">
Create workspace-specific AI settings only if this workspace needs to override the active
instance defaults.
</p>
<div>
<Button onclick={onToggleOverride} variant="default" unifiedSize="sm">
{showWorkspaceOverrideEditor ? 'Hide override form' : 'Override for this workspace'}
</Button>
</div>
</div>
</SettingCard>
@@ -25,13 +25,20 @@
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import InstanceAISettings from '$lib/components/instanceSettings/InstanceAISettings.svelte'
const settingsSteps = [
{ id: 'Core', label: 'Core' },
{ id: 'Auth/OAuth/SAML', label: 'Authentication' }
] as const
const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types']
const AI_STEP_INDEX = settingsSteps.length
const wizardStepLabels = [
...settingsSteps.map((s) => s.label),
'AI',
'Root login & Resource Types'
]
const fullStepLabels = ['Settings', 'Root login & Resource Types']
@@ -67,6 +74,7 @@
})
let instanceSettings: InstanceSettings | undefined = $state()
let instanceAiSettings: InstanceAISettings | undefined = $state()
function isSettingsStep(step: number): boolean {
return step < settingsSteps.length
@@ -148,6 +156,9 @@
let passwordValid = $derived(newPassword.length >= 2)
let accountFormValid = $derived(emailValid && passwordValid)
// --- AI step state ---
let aiHasUnsavedChanges = $state(false)
// --- EE license key warning ---
let showLicenseKeyWarning = $state(false)
let pendingNextCallback: (() => void) | undefined = $state(undefined)
@@ -168,9 +179,20 @@
let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso')
let yamlMode = $state(false)
function handleNavigate(newTab: string) {
if (newTab === fullTab) return
function isAiStepActive(): boolean {
return (
(mode === 'wizard' && wizardStep === AI_STEP_INDEX) ||
(mode === 'full' && fullStep === 0 && fullTab === 'ai' && !yamlMode)
)
}
async function handleNavigate(newTab: string): Promise<boolean> {
if (newTab === fullTab) return true
if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) {
return false
}
fullTab = newTab
return true
}
// --- Settings search (full mode) ---
@@ -180,7 +202,10 @@
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
async function handleSearchSelect(item: SearchableSettingItem) {
handleNavigate(item.tabId)
const didNavigate = await handleNavigate(item.tabId)
if (!didNavigate) {
return
}
if (item.settingKey) {
clearTimeout(scrollTimeout)
clearTimeout(highlightTimeout)
@@ -202,7 +227,7 @@
})
/** Check if we need to warn about missing EE license key before proceeding */
function proceedFromCore(callback: () => void) {
async function proceedFromCore(callback: () => void) {
const leavingSettings =
(mode === 'wizard' && wizardStep === 0) || (mode === 'full' && fullStep === 0)
if (leavingSettings && isEeImage() && isLicenseKeyEmpty()) {
@@ -210,12 +235,16 @@
showLicenseKeyWarning = true
return
}
saveAndProceed(callback)
await saveAndProceed(callback)
}
/** Auto-save dirty settings, then run the callback */
async function saveAndProceed(callback: () => void) {
if (yamlMode) {
if (isAiStepActive()) {
if (!((await instanceAiSettings?.persistBeforeExit()) ?? true)) {
return
}
} else if (yamlMode) {
// In YAML mode, sync editor → form, then bulk-save everything
if (!instanceSettings?.syncBeforeDiff()) return
await instanceSettings.saveSettings()
@@ -231,11 +260,14 @@
callback()
}
function switchToFullMode() {
async function switchToFullMode() {
mode = 'full'
}
function switchToWizardMode() {
async function switchToWizardMode() {
if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) {
return
}
yamlMode = false
fullStep = 0
mode = 'wizard'
@@ -461,6 +493,13 @@
tab={settingsSteps[wizardStep].id}
/>
{/key}
{:else if wizardStep === AI_STEP_INDEX}
<InstanceAISettings
bind:this={instanceAiSettings}
bind:hasUnsavedChanges={aiHasUnsavedChanges}
disableChatOffset
showHubSync
/>
{:else}
{@render accountSetupContent()}
{/if}
@@ -505,19 +544,28 @@
{/if}
<div class="flex-1 min-w-0 h-full overflow-auto px-4">
<InstanceSettings
bind:this={instanceSettings}
hideTabs
tab={instanceSettingsCategory}
{authSubTab}
bind:yamlMode
onNavigateToTab={(category) => {
const targetTab = categoryToTabMap[category]
if (targetTab) {
handleNavigate(targetTab)
}
}}
/>
{#if fullTab === 'ai' && !yamlMode}
<InstanceAISettings
bind:this={instanceAiSettings}
bind:hasUnsavedChanges={aiHasUnsavedChanges}
disableChatOffset
showHubSync
/>
{:else}
<InstanceSettings
bind:this={instanceSettings}
hideTabs
tab={instanceSettingsCategory}
{authSubTab}
bind:yamlMode
onNavigateToTab={(category) => {
const targetTab = categoryToTabMap[category]
if (targetTab) {
handleNavigate(targetTab)
}
}}
/>
{/if}
</div>
</div>
{:else}
@@ -19,10 +19,12 @@
import {
OauthService,
WorkspaceService,
ResourceService,
SettingService,
type AIConfig,
type ErrorHandler
type ErrorHandler,
type GetCopilotSettingsStateResponse,
type InstanceAISummary,
type GetSettingsResponse
} from '$lib/gen'
import {
enterpriseLicense,
@@ -60,7 +62,6 @@
convertDucklakeSettingsFromBackend,
type DucklakeSettingsType
} from '$lib/components/workspaceSettings/DucklakeSettings.svelte'
import { AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import CollapseLink from '$lib/components/CollapseLink.svelte'
@@ -112,19 +113,12 @@
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
let aiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let codeCompletionModel: string | undefined = $state(undefined)
let defaultModel: string | undefined = $state(undefined)
let customPrompts: Record<string, string> = $state({})
let maxTokensPerModel: Record<string, number> = $state({})
// Track initial AI config for unsaved changes detection
let initialAiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let initialCodeCompletionModel: string | undefined = $state(undefined)
let initialDefaultModel: string | undefined = $state(undefined)
let initialCustomPrompts: Record<string, string> = $state({})
let initialMaxTokensPerModel: Record<string, number> = $state({})
let hasInstanceAiConfig = $state(false)
let usesInstanceAiConfig = $state(false)
let instanceAiSummary: InstanceAISummary | undefined = $state(undefined)
let aiInitialConfig: AIConfig | undefined = $state(undefined)
let aiSettingsComponent: AISettings | undefined = $state(undefined)
let hasAiSettingsChanges = $state(false)
// Track initial deploy settings for unsaved changes detection
let initialWorkspaceToDeployTo: string | undefined = $state(undefined)
let initialDeployUiSettings: {
@@ -227,14 +221,6 @@
return currentValue !== initialValue
})
// Derived state for checking unsaved changes in AI settings
let hasAiSettingsChanges = $derived.by(() => {
if (tab !== 'ai') return false
const changes = getAiSettingsInitialAndModifiedValues()
if (!changes.savedValue || !changes.modifiedValue) return false
return hasUnsavedChanges(changes.savedValue, changes.modifiedValue)
})
// Derived state for checking unsaved changes in deployment settings
let hasDeploySettingsChanges = $derived.by(() => {
if (tab !== 'deploy_to') return false
@@ -320,8 +306,6 @@
$page.url.searchParams.get('tab') === 'teams' ? 'teams_commands' : 'slack_commands'
)
let usingOpenaiClientCredentialsOauth = $state(false)
let loadedSettings = $state(false)
let oauths: Record<string, any> = $state({})
@@ -489,7 +473,17 @@
}
async function loadSettings(): Promise<void> {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
const [settings, copilotSettingsState]: [
GetSettingsResponse,
GetCopilotSettingsStateResponse
] = await Promise.all([
WorkspaceService.getSettings({
workspace: $workspaceStore!
}),
WorkspaceService.getCopilotSettingsState({
workspace: $workspaceStore!
})
])
slack_team_name = settings.slack_name
teams_team_id = settings.teams_team_id
teams_team_name = settings.teams_team_name
@@ -508,23 +502,10 @@
workspaceToDeployTo = settings.deploy_to
webhook = settings.webhook
aiProviders = settings.ai_config?.providers ?? {}
defaultModel = settings.ai_config?.default_model?.model
codeCompletionModel = settings.ai_config?.code_completion_model?.model
customPrompts = settings.ai_config?.custom_prompts ?? {}
maxTokensPerModel = settings.ai_config?.max_tokens_per_model ?? {}
for (const mode of Object.values(AIMode)) {
if (!(mode in customPrompts)) {
customPrompts[mode] = ''
}
}
// Store initial AI config state for unsaved changes detection
initialAiProviders = clone(aiProviders)
initialDefaultModel = defaultModel
initialCodeCompletionModel = codeCompletionModel
initialCustomPrompts = clone(customPrompts)
initialMaxTokensPerModel = clone(maxTokensPerModel)
aiInitialConfig = settings.ai_config ?? {}
hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config
usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config
instanceAiSummary = copilotSettingsState.instance_ai_summary
const errorHandler = settings.error_handler as
| { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean }
| undefined
@@ -600,12 +581,6 @@
// Store initial success handler state for unsaved changes detection
initialSuccessHandlerScriptPath = successHandlerScriptPath
// check openai_client_credentials_oauth
usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({
workspace: $workspaceStore!,
path: 'openai_client_credentials_oauth'
})
loadedSettings = true
}
@@ -816,36 +791,6 @@
)
}
// Function to check if there are unsaved changes in AI settings
function getAiSettingsInitialAndModifiedValues() {
const savedValue = {
aiProviders: initialAiProviders,
defaultModel: initialDefaultModel,
codeCompletionModel: initialCodeCompletionModel,
customPrompts: initialCustomPrompts,
maxTokensPerModel: initialMaxTokensPerModel
}
const modifiedValue = {
aiProviders: aiProviders,
defaultModel: defaultModel,
codeCompletionModel: codeCompletionModel,
customPrompts: customPrompts,
maxTokensPerModel: maxTokensPerModel
}
return { savedValue, modifiedValue }
}
// Function to discard unsaved AI settings changes
function discardAiSettingsChanges() {
aiProviders = clone(initialAiProviders)
defaultModel = initialDefaultModel
codeCompletionModel = initialCodeCompletionModel
customPrompts = clone(initialCustomPrompts)
maxTokensPerModel = clone(initialMaxTokensPerModel)
}
// Function to check if there are unsaved changes in storage settings
function getStorageSettingsInitialAndModifiedValues() {
return {
@@ -1017,7 +962,9 @@
case 'windmill_data_tables':
return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} }
case 'ai':
return getAiSettingsInitialAndModifiedValues()
return hasAiSettingsChanges
? { savedValue: { changed: false }, modifiedValue: { changed: true } }
: { savedValue: {}, modifiedValue: {} }
case 'windmill_lfs':
return getStorageSettingsInitialAndModifiedValues()
case 'volume_storage':
@@ -1059,7 +1006,7 @@
function discardAllChanges() {
switch (tab) {
case 'ai':
discardAiSettingsChanges()
aiSettingsComponent?.discard()
break
case 'windmill_lfs':
discardStorageSettingsChanges()
@@ -1830,21 +1777,19 @@ export async function main(
/>
{:else if tab == 'ai'}
<AISettings
bind:aiProviders
bind:codeCompletionModel
bind:defaultModel
bind:customPrompts
bind:maxTokensPerModel
bind:usingOpenaiClientCredentialsOauth
hasUnsavedChanges={hasAiSettingsChanges}
onDiscard={discardAiSettingsChanges}
onSave={() => {
// Update initial state after successful save
initialAiProviders = clone(aiProviders)
initialDefaultModel = defaultModel
initialCodeCompletionModel = codeCompletionModel
initialCustomPrompts = clone(customPrompts)
initialMaxTokensPerModel = clone(maxTokensPerModel)
bind:this={aiSettingsComponent}
initialConfig={aiInitialConfig}
bind:hasUnsavedChanges={hasAiSettingsChanges}
{hasInstanceAiConfig}
{usesInstanceAiConfig}
{instanceAiSummary}
onSave={(copilotSettingsState) => {
if (!copilotSettingsState) {
return
}
hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config
usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config
instanceAiSummary = copilotSettingsState.instance_ai_summary
}}
/>
{:else if tab == 'windmill_data_tables'}