From e96f952d812e3d5170a4f5e3428e936ee3bec8de Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 22 Jun 2026 12:35:20 +0200 Subject: [PATCH] feat: add free Claude Opus tier with per-user token limit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...d2f1635c01dfc46a4b54673649a40edd65253.json | 14 + ...1cd1a5892d2316703cbebc6dfcbc368998f2a.json | 22 ++ ...5bf008219026e1ac90f6d71ff9702360090ae.json | 20 ++ ...4e7f1f75073565e06382a6bc9168651c461b4.json | 15 ++ backend/ee-repo-ref.txt | 2 +- ...0260622072905_ai_free_token_usage.down.sql | 2 + .../20260622072905_ai_free_token_usage.up.sql | 16 ++ backend/summarized_schema.txt | 2 + backend/windmill-api/src/ai.rs | 247 +++++++++++------- backend/windmill-api/src/ai_free_tier_oss.rs | 59 +++++ backend/windmill-api/src/lib.rs | 3 + backend/windmill-api/src/workspaces.rs | 8 +- 12 files changed, 311 insertions(+), 99 deletions(-) create mode 100644 backend/.sqlx/query-1d2e1172a0859faf59bf5bf2f6ed2f1635c01dfc46a4b54673649a40edd65253.json create mode 100644 backend/.sqlx/query-41d120206a70dd9dcffc859f3891cd1a5892d2316703cbebc6dfcbc368998f2a.json create mode 100644 backend/.sqlx/query-63b7ebc31b651116b6d85eb7d585bf008219026e1ac90f6d71ff9702360090ae.json create mode 100644 backend/.sqlx/query-d2056e9f31d82daa1cbd13f17084e7f1f75073565e06382a6bc9168651c461b4.json create mode 100644 backend/migrations/20260622072905_ai_free_token_usage.down.sql create mode 100644 backend/migrations/20260622072905_ai_free_token_usage.up.sql create mode 100644 backend/windmill-api/src/ai_free_tier_oss.rs diff --git a/backend/.sqlx/query-1d2e1172a0859faf59bf5bf2f6ed2f1635c01dfc46a4b54673649a40edd65253.json b/backend/.sqlx/query-1d2e1172a0859faf59bf5bf2f6ed2f1635c01dfc46a4b54673649a40edd65253.json new file mode 100644 index 0000000000..9dc88756fd --- /dev/null +++ b/backend/.sqlx/query-1d2e1172a0859faf59bf5bf2f6ed2f1635c01dfc46a4b54673649a40edd65253.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ai_free_token_daily_usage (day, tokens_used, updated_at)\n VALUES ((now() at time zone 'utc')::date, $1, now())\n ON CONFLICT (day) DO UPDATE\n SET tokens_used = ai_free_token_daily_usage.tokens_used + EXCLUDED.tokens_used,\n updated_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "1d2e1172a0859faf59bf5bf2f6ed2f1635c01dfc46a4b54673649a40edd65253" +} diff --git a/backend/.sqlx/query-41d120206a70dd9dcffc859f3891cd1a5892d2316703cbebc6dfcbc368998f2a.json b/backend/.sqlx/query-41d120206a70dd9dcffc859f3891cd1a5892d2316703cbebc6dfcbc368998f2a.json new file mode 100644 index 0000000000..9626f5e20b --- /dev/null +++ b/backend/.sqlx/query-41d120206a70dd9dcffc859f3891cd1a5892d2316703cbebc6dfcbc368998f2a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT tokens_used FROM ai_free_token_usage WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "tokens_used", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "41d120206a70dd9dcffc859f3891cd1a5892d2316703cbebc6dfcbc368998f2a" +} diff --git a/backend/.sqlx/query-63b7ebc31b651116b6d85eb7d585bf008219026e1ac90f6d71ff9702360090ae.json b/backend/.sqlx/query-63b7ebc31b651116b6d85eb7d585bf008219026e1ac90f6d71ff9702360090ae.json new file mode 100644 index 0000000000..739b11e2b1 --- /dev/null +++ b/backend/.sqlx/query-63b7ebc31b651116b6d85eb7d585bf008219026e1ac90f6d71ff9702360090ae.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT tokens_used FROM ai_free_token_daily_usage WHERE day = (now() at time zone 'utc')::date", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "tokens_used", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "63b7ebc31b651116b6d85eb7d585bf008219026e1ac90f6d71ff9702360090ae" +} diff --git a/backend/.sqlx/query-d2056e9f31d82daa1cbd13f17084e7f1f75073565e06382a6bc9168651c461b4.json b/backend/.sqlx/query-d2056e9f31d82daa1cbd13f17084e7f1f75073565e06382a6bc9168651c461b4.json new file mode 100644 index 0000000000..848090e0ca --- /dev/null +++ b/backend/.sqlx/query-d2056e9f31d82daa1cbd13f17084e7f1f75073565e06382a6bc9168651c461b4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ai_free_token_usage (email, tokens_used, updated_at)\n VALUES ($1, $2, now())\n ON CONFLICT (email) DO UPDATE\n SET tokens_used = ai_free_token_usage.tokens_used + EXCLUDED.tokens_used,\n updated_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d2056e9f31d82daa1cbd13f17084e7f1f75073565e06382a6bc9168651c461b4" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 151e85295b..fc292591e6 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ba677ea142011462ad4dfe77e8375a6dd274cdef +b2712bc1411b29ec1c95466831bf4c7982447fd8 \ No newline at end of file diff --git a/backend/migrations/20260622072905_ai_free_token_usage.down.sql b/backend/migrations/20260622072905_ai_free_token_usage.down.sql new file mode 100644 index 0000000000..95879034e4 --- /dev/null +++ b/backend/migrations/20260622072905_ai_free_token_usage.down.sql @@ -0,0 +1,2 @@ +DROP TABLE ai_free_token_daily_usage; +DROP TABLE ai_free_token_usage; diff --git a/backend/migrations/20260622072905_ai_free_token_usage.up.sql b/backend/migrations/20260622072905_ai_free_token_usage.up.sql new file mode 100644 index 0000000000..f2bc0af3ce --- /dev/null +++ b/backend/migrations/20260622072905_ai_free_token_usage.up.sql @@ -0,0 +1,16 @@ +-- Per-user lifetime usage of the Windmill-provided free Claude Opus tier. +-- Keyed by normalized email so the allowance is shared across all of a user's +-- workspaces (and resistant to +tag / gmail-dot aliasing). +CREATE TABLE ai_free_token_usage ( + email VARCHAR(255) PRIMARY KEY, + tokens_used BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Instance-wide daily ceiling for the free tier (a cost kill-switch independent of +-- per-user budgets). One row per UTC day. +CREATE TABLE ai_free_token_daily_usage ( + day DATE PRIMARY KEY, + tokens_used BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 6553abb5cc..3c88f16d0c 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -37,6 +37,8 @@ account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), clien FK: (workspace_id) -> workspace(id) agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char) ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts) +ai_free_token_daily_usage: day(date), tokens_used(bigint), updated_at(ts) +ai_free_token_usage: email(char), tokens_used(bigint), updated_at(ts) alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text) app: id(bigint), workspace_id(char), path(char), summary(char), policy(jsonb), versions(bigint[]), extra_perms(jsonb), draft_only(bool), custom_path(text), labels(text[]) FK: (workspace_id) -> workspace(id) diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 6195fe5644..b7a21627e1 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -646,83 +646,105 @@ async fn proxy( check_scopes(&authed, || format!("resources:read:{}", resource_path))?; } - let mut credentials = match workspace_cache { - Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { - request_cache.credentials - } - _ => { - 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?; + // Set when serving the request through the free Claude Opus tier (Windmill's own + // Anthropic key). Holds the per-user concurrency lock and drives response metering. + let mut free_lease: Option = None; + let mut credentials = 'cred: { + match workspace_cache { + Some(request_cache) + if !request_cache.is_expired() && forced_resource_path.is_none() => + { + request_cache.credentials + } + _ => { + 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?; - 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::(v.clone()).ok()) - .is_some_and(|config| config.has_providers()); + 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::(v.clone()).ok()) + .is_some_and(|config| config.has_providers()); - 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?; + 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?; - 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(), - )); + match instance_config { + Some(config) => ( + config, + "admins".to_string(), + Some(current_instance_ai_config_revision()), + ), + None => { + // Nothing configured: fall back to the free Claude Opus + // tier (EE-only) if Windmill lends its own Anthropic key + // and the user is under their lifetime/daily budget. Errors + // once exhausted or on a concurrent request; None otherwise. + if let Some((free_credentials, lease)) = + crate::ai_free_tier_oss::resolve_free_tier_credentials( + &provider, + &db, + &ai_path, + &authed.email, + ) + .await? + { + free_lease = Some(lease); + break 'cred free_credentials; + } + return Err(Error::internal_err( + "AI resource not configured".to_string(), + )); + } } } + }; + + let mut ai_config = serde_json::from_value::(ai_config_value) + .map_err(|e| Error::BadRequest(e.to_string()))?; + + let provider_config = ai_config + .providers + .as_mut() + .and_then(|providers| providers.remove(&provider)) + .ok_or_else(|| { + Error::BadRequest(format!("Provider {:?} not configured", provider)) + })?; + + 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 mut ai_config = serde_json::from_value::(ai_config_value) - .map_err(|e| Error::BadRequest(e.to_string()))?; - - let provider_config = ai_config - .providers - .as_mut() - .and_then(|providers| providers.remove(&provider)) - .ok_or_else(|| { - Error::BadRequest(format!("Provider {:?} not configured", provider)) - })?; - - 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, - ) - }; - - // For user-specified resources, fetch through an RLS-scoped - // connection so PostgreSQL row-level security enforces the same - // folder/group boundaries as the regular resource API. For the - // workspace/instance ai_config path, the resource_path was already - // validated by an admin/devops user when configuring the workspace, - // so the raw pool is used. - let resource = if is_user_specified_resource { + // For user-specified resources, fetch through an RLS-scoped + // connection so PostgreSQL row-level security enforces the same + // folder/group boundaries as the regular resource API. For the + // workspace/instance ai_config path, the resource_path was already + // validated by an admin/devops user when configuring the workspace, + // so the raw pool is used. + let resource = if is_user_specified_resource { let mut tx = user_db.clone().begin(&authed).await?; let res = sqlx::query_scalar::<_, Option>>>( "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", @@ -745,38 +767,45 @@ async fn proxy( .ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))? .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?; - let resource = serde_json::from_str::(resource.0.get()) - .map_err(|e| Error::BadRequest(e.to_string()))?; + let resource = serde_json::from_str::(resource.0.get()) + .map_err(|e| Error::BadRequest(e.to_string()))?; - // Enforce RLS on $var: resolution when the resource path was - // user-specified (X-Resource-Path header) so users can only read - // variables they have permission to access. - let enforce_authed = if is_user_specified_resource { - Some(&authed) - } else { - None - }; - let credentials = resolve_provider_credentials( - &provider, - &db, - &resource_workspace, - resource, - enforce_authed, - ) - .await?; - if save_to_cache { - AI_REQUEST_CACHE.insert( - (w_id.clone(), provider.clone()), - ExpiringProviderCredentials::new( - credentials.clone(), - instance_ai_config_revision, - ), - ); + // Enforce RLS on $var: resolution when the resource path was + // user-specified (X-Resource-Path header) so users can only read + // variables they have permission to access. + let enforce_authed = if is_user_specified_resource { + Some(&authed) + } else { + None + }; + let credentials = resolve_provider_credentials( + &provider, + &db, + &resource_workspace, + resource, + enforce_authed, + ) + .await?; + if save_to_cache { + AI_REQUEST_CACHE.insert( + (w_id.clone(), provider.clone()), + ExpiringProviderCredentials::new( + credentials.clone(), + instance_ai_config_revision, + ), + ); + } + credentials } - credentials } }; + // Free tier: pin the model and clamp max_tokens server-side before forwarding, + // since the request body is otherwise client-controlled. + if free_lease.is_some() { + body = crate::ai_free_tier_oss::enforce_free_tier_body(&body)?; + } + if let Some(fim_transform) = maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)? { @@ -918,8 +947,32 @@ async fn proxy( let status_code = response.status(); let headers = response.headers().clone(); + let is_sse = is_sse_response(&headers); + + // Free tier: meter Anthropic token usage from the response and charge it to the + // user's budget, holding the per-user lock (via the lease) until usage is recorded. + // The frontend always streams Anthropic (SSE); the JSON path is handled for + // completeness. + if let Some(lease) = free_lease { + let body = if is_sse { + axum::body::Body::from_stream(inject_keepalives( + Box::pin(crate::ai_free_tier_oss::meter_anthropic_usage( + response.bytes_stream(), + db.clone(), + lease, + )), + Duration::from_secs(KEEPALIVE_INTERVAL_SECS), + )) + } else { + let bytes = response.bytes().await.map_err(to_anyhow)?; + crate::ai_free_tier_oss::record_anthropic_json_usage(db.clone(), lease, &bytes); + axum::body::Body::from(bytes) + }; + return Ok((status_code, headers, body)); + } + let stream = response.bytes_stream(); - let body = if is_sse_response(&headers) { + let body = if is_sse { axum::body::Body::from_stream(inject_keepalives( stream, Duration::from_secs(KEEPALIVE_INTERVAL_SECS), diff --git a/backend/windmill-api/src/ai_free_tier_oss.rs b/backend/windmill-api/src/ai_free_tier_oss.rs new file mode 100644 index 0000000000..d4116a9eb4 --- /dev/null +++ b/backend/windmill-api/src/ai_free_tier_oss.rs @@ -0,0 +1,59 @@ +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::ai_free_tier_ee::*; + +// Open-source build: the free Claude Opus tier does not exist. These stubs make the +// callers in `ai.rs` / `workspaces.rs` compile while disabling the feature entirely — +// `resolve_free_tier_credentials` never opts in, so the proxy falls through to its +// normal "AI resource not configured" path and the copilot stays hidden. + +#[cfg(not(feature = "private"))] +use crate::ai::AIConfig; +#[cfg(not(feature = "private"))] +use crate::db::DB; +#[cfg(not(feature = "private"))] +use axum::body::Bytes; +#[cfg(not(feature = "private"))] +use windmill_ai::ai_providers::AIProvider; +#[cfg(not(feature = "private"))] +use windmill_ai::credentials::ProviderCredentials; +#[cfg(not(feature = "private"))] +use windmill_common::error::Result; + +#[cfg(not(feature = "private"))] +pub struct FreeTierLease; + +#[cfg(not(feature = "private"))] +pub async fn resolve_free_tier_credentials( + _provider: &AIProvider, + _db: &DB, + _ai_path: &str, + _email: &str, +) -> Result> { + Ok(None) +} + +#[cfg(not(feature = "private"))] +pub fn enforce_free_tier_body(body: &Bytes) -> Result { + Ok(body.clone()) +} + +#[cfg(not(feature = "private"))] +pub async fn free_tier_copilot_config(_db: &DB, _email: &str) -> Result> { + Ok(None) +} + +#[cfg(not(feature = "private"))] +pub fn record_anthropic_json_usage(_db: DB, _lease: FreeTierLease, _bytes: &[u8]) {} + +#[cfg(not(feature = "private"))] +pub fn meter_anthropic_usage( + upstream: S, + _db: DB, + _lease: FreeTierLease, +) -> impl futures::Stream> +where + S: futures::Stream> + Unpin, +{ + upstream +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 0f5f9c40ca..c34a153619 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -66,6 +66,9 @@ use crate::scim_oss::has_scim_token; use windmill_common::error::AppError; mod ai; +#[cfg(feature = "private")] +mod ai_free_tier_ee; +mod ai_free_tier_oss; mod apps; pub mod args; mod audit; diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 1e26a20e05..2f5622a6b8 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -18,7 +18,6 @@ use crate::teams_oss::{ connect_teams, edit_teams_command, run_teams_message_test_job, workspaces_list_available_teams_channels, workspaces_list_available_teams_ids, }; - use axum::{ extract::{Extension, Path}, routing::{get, post}, @@ -177,6 +176,7 @@ struct EditCopilotConfigResponse { } async fn get_copilot_info( + authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { @@ -202,6 +202,12 @@ async fn get_copilot_info( Ok(Json( serde_json::from_value::(instance_config).unwrap_or_default(), )) + } else if let Some(free_config) = + crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await? + { + // Nothing configured: surface the free Claude Opus tier (EE-only) when it is + // available to this user. + Ok(Json(free_config)) } else { Ok(Json(AIConfig::default())) }