From 76bd706112fcbdcaeec62395fc54e98049d0c00e Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 14 Jul 2026 17:30:48 +0200 Subject: [PATCH] [ee] feat: surface free AI tier state and make its metering abort-proof Makes the free Windmill AI tier legible to the user and closes an abuse hole. Backend: - AIConfig gains a response-only free_tier marker (skip_deserializing so a client can't store a forged one via edit_copilot_config). get_copilot_info keeps returning it once the grant is spent, so the client knows AI is off because the grant ran out, not because nothing was configured. - Per-user grant becomes one-time (migration drops the day key from ai_free_token_usage); the daily table stays as the instance kill-switch. - Reserve-then-reconcile metering (see EE commit) so a mid-stream disconnect can no longer dodge the usage report and get metered zero. Frontend: - copilotInfo carries freeTier; model settings show a "Free" pill and a usage meter that warns past 80%. - The home chat and the session chat show a dedicated "you've used your free Windmill AI, add your own API key" state instead of the generic "no provider configured" one. - A failed send re-fetches copilot_info so the exhausted state (and its banner) appears live, without a page reload. Bumps ee-repo-ref.txt to the matching EE commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...4fc5e65995ce81e622c174a89befc1a527e5.json} | 4 +- ...0d90736fda75fd059254b7a105b51083a4416.json | 15 ------ ...de2ffaf98a111c5dd0d612f4bfa8bb00e5c4.json} | 4 +- ...7e1bf2c9a0d11e9841caafca62d85c9fb4c8f.json | 15 ++++++ backend/ee-repo-ref.txt | 2 +- .../20260622072905_ai_free_token_usage.up.sql | 19 ++++--- backend/summarized_schema.txt | 2 +- backend/windmill-api/openapi.yaml | 18 +++++++ backend/windmill-api/src/ai.rs | 18 +++++++ backend/windmill-api/src/workspaces.rs | 5 +- frontend/src/lib/aiStore.ts | 20 ++++++-- .../lib/components/copilot/chat/AIChat.svelte | 28 +++++++---- .../copilot/chat/AIChatDisplay.svelte | 37 ++++++++++++++ .../copilot/chat/AIChatManager.svelte.ts | 25 ++++++++++ .../copilot/chat/AIChatModelSettings.svelte | 50 +++++++++++++++++++ .../src/lib/components/home/HomeAIChat.svelte | 18 +++++-- 16 files changed, 228 insertions(+), 52 deletions(-) rename backend/.sqlx/{query-445483e44858098da49a2e8659cf2a2e9d86ef70d0e667f0e7b291dde879608e.json => query-247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5.json} (57%) delete mode 100644 backend/.sqlx/query-5d4138a0d49fe5f279c4d63c7ae0d90736fda75fd059254b7a105b51083a4416.json rename backend/.sqlx/{query-b3f8dc3254e15c5818ae26874c4754b11e357cad32b41ca30ce2a46f89fa8be2.json => query-60b9618eb975a257fcdb6c310ac8de2ffaf98a111c5dd0d612f4bfa8bb00e5c4.json} (50%) create mode 100644 backend/.sqlx/query-f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f.json diff --git a/backend/.sqlx/query-445483e44858098da49a2e8659cf2a2e9d86ef70d0e667f0e7b291dde879608e.json b/backend/.sqlx/query-247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5.json similarity index 57% rename from backend/.sqlx/query-445483e44858098da49a2e8659cf2a2e9d86ef70d0e667f0e7b291dde879608e.json rename to backend/.sqlx/query-247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5.json index 3c34a5e59c..4ae74ff53c 100644 --- a/backend/.sqlx/query-445483e44858098da49a2e8659cf2a2e9d86ef70d0e667f0e7b291dde879608e.json +++ b/backend/.sqlx/query-247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT cost_nanos FROM ai_free_token_usage\n WHERE email = $1 AND day = (now() at time zone 'utc')::date", + "query": "SELECT cost_nanos FROM ai_free_token_usage WHERE email = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ false ] }, - "hash": "445483e44858098da49a2e8659cf2a2e9d86ef70d0e667f0e7b291dde879608e" + "hash": "247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5" } diff --git a/backend/.sqlx/query-5d4138a0d49fe5f279c4d63c7ae0d90736fda75fd059254b7a105b51083a4416.json b/backend/.sqlx/query-5d4138a0d49fe5f279c4d63c7ae0d90736fda75fd059254b7a105b51083a4416.json deleted file mode 100644 index 184dc03f16..0000000000 --- a/backend/.sqlx/query-5d4138a0d49fe5f279c4d63c7ae0d90736fda75fd059254b7a105b51083a4416.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO ai_free_token_usage (email, day, cost_nanos, updated_at)\n VALUES ($1, (now() at time zone 'utc')::date, $2, now())\n ON CONFLICT (email, day) DO UPDATE\n SET cost_nanos = ai_free_token_usage.cost_nanos + EXCLUDED.cost_nanos,\n updated_at = now()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "5d4138a0d49fe5f279c4d63c7ae0d90736fda75fd059254b7a105b51083a4416" -} diff --git a/backend/.sqlx/query-b3f8dc3254e15c5818ae26874c4754b11e357cad32b41ca30ce2a46f89fa8be2.json b/backend/.sqlx/query-60b9618eb975a257fcdb6c310ac8de2ffaf98a111c5dd0d612f4bfa8bb00e5c4.json similarity index 50% rename from backend/.sqlx/query-b3f8dc3254e15c5818ae26874c4754b11e357cad32b41ca30ce2a46f89fa8be2.json rename to backend/.sqlx/query-60b9618eb975a257fcdb6c310ac8de2ffaf98a111c5dd0d612f4bfa8bb00e5c4.json index 9418970aa8..85d41c450f 100644 --- a/backend/.sqlx/query-b3f8dc3254e15c5818ae26874c4754b11e357cad32b41ca30ce2a46f89fa8be2.json +++ b/backend/.sqlx/query-60b9618eb975a257fcdb6c310ac8de2ffaf98a111c5dd0d612f4bfa8bb00e5c4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ((now() at time zone 'utc')::date, $1, now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = ai_free_token_daily_usage.cost_nanos + EXCLUDED.cost_nanos,\n updated_at = now()", + "query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ((now() at time zone 'utc')::date, GREATEST(0, $1::bigint), now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_daily_usage.cost_nanos + $1::bigint),\n updated_at = now()", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "b3f8dc3254e15c5818ae26874c4754b11e357cad32b41ca30ce2a46f89fa8be2" + "hash": "60b9618eb975a257fcdb6c310ac8de2ffaf98a111c5dd0d612f4bfa8bb00e5c4" } diff --git a/backend/.sqlx/query-f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f.json b/backend/.sqlx/query-f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f.json new file mode 100644 index 0000000000..32f7e8cf1f --- /dev/null +++ b/backend/.sqlx/query-f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, GREATEST(0, $2::bigint), now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_usage.cost_nanos + $2::bigint),\n updated_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 2fbdacb17d..03f8422000 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -583a5c32eb9e04a7bb9a2a99cf7fb6cd4fe9f7f9 \ No newline at end of file +593ad8e171478758e95785f91c5d9548e09957bf \ No newline at end of file diff --git a/backend/migrations/20260622072905_ai_free_token_usage.up.sql b/backend/migrations/20260622072905_ai_free_token_usage.up.sql index c782ee9565..ae0cf47e45 100644 --- a/backend/migrations/20260622072905_ai_free_token_usage.up.sql +++ b/backend/migrations/20260622072905_ai_free_token_usage.up.sql @@ -1,18 +1,17 @@ --- Per-user DAILY usage of the Windmill-provided free AI tier, measured as cost in --- nano-dollars (1e-9 USD) rather than raw tokens — a prompt-cache hit costs a fraction --- of a fresh input token, so a token count wildly overstates the real bill. Keyed by --- normalized email so the allowance is shared across a user's workspaces (and is --- resistant to +tag / gmail-dot aliasing). +-- One-time grant of the Windmill-provided free AI tier, measured as cost in nano-dollars +-- (1e-9 USD) rather than raw tokens — a prompt-cache hit costs a fraction of a fresh input +-- token, so a token count wildly overstates the real bill. The grant never resets: once +-- spent, the user must bring their own API key. Keyed by normalized email so the allowance +-- is shared across a user's workspaces (and is resistant to +tag / gmail-dot aliasing). CREATE TABLE ai_free_token_usage ( - email VARCHAR(255) NOT NULL, - day DATE NOT NULL, + email VARCHAR(255) PRIMARY KEY, cost_nanos BIGINT NOT NULL DEFAULT 0, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (email, day) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Instance-wide daily cost ceiling (nano-dollars) for the free tier — a kill-switch --- independent of the per-user budget. One row per UTC day. +-- independent of the per-user grant, bounding the blast radius of a bad day. One row per +-- UTC day. CREATE TABLE ai_free_token_daily_usage ( day DATE PRIMARY KEY, cost_nanos BIGINT NOT NULL DEFAULT 0, diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index c12cd0765e..abd39814b6 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -38,7 +38,7 @@ account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), clien 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), cost_nanos(bigint), updated_at(ts) -ai_free_token_usage: email(char), day(date), cost_nanos(bigint), updated_at(ts) +ai_free_token_usage: email(char), cost_nanos(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/openapi.yaml b/backend/windmill-api/openapi.yaml index b6b254e0ec..6a214e8d87 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -23524,6 +23524,24 @@ components: type: integer minimum: 1 maximum: 2000000 + free_tier: + $ref: "#/components/schemas/FreeTierInfo" + + FreeTierInfo: + type: object + description: >- + Read-only. Present when the workspace has no AI provider of its own and is running + on Windmill's free tier. Ignored on write. + properties: + exhausted: + type: boolean + description: The one-time grant is spent; no provider is served and the user must add their own API key. + used_ratio: + type: number + description: Fraction of the grant consumed, 0 to 1. + required: + - exhausted + - used_ratio InstanceAIProviderSummary: type: object diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index ff4d20f061..6bf612f828 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -365,6 +365,19 @@ impl ExpiringProviderCredentials { } } +/// Set on the copilot config when the workspace has no AI provider of its own and is +/// running on Windmill's free tier, so the client can label the lent model as free, warn +/// before the grant runs out, and tell the user to add their own key once it has — rather +/// than showing the same "no provider configured" state a never-configured workspace gets. +#[derive(Serialize, Deserialize, Debug, Default, Clone)] +pub struct FreeTierInfo { + /// The grant is spent: no provider is served and the user must bring their own key. + pub exhausted: bool, + /// Fraction of the grant consumed, 0.0..=1.0. A ratio, not a dollar amount — the + /// pricing model stays server-side. + pub used_ratio: f64, +} + #[derive(Serialize, Deserialize, Debug, Default)] pub struct AIConfig { #[serde(skip_serializing_if = "Option::is_none")] @@ -379,6 +392,11 @@ pub struct AIConfig { pub custom_prompts: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens_per_model: Option>, + /// Response-only: this same struct is the request body for saving a workspace's AI + /// config, and `skip_deserializing` is what stops a client from storing a forged + /// free-tier marker. Only the server sets it, per-request. + #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] + pub free_tier: Option, } impl AIConfig { diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 2f5622a6b8..76fa2f74bf 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -205,8 +205,9 @@ async fn get_copilot_info( } 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. + // Nothing configured: fall back to Windmill's free tier (EE-only). The config + // carries a `free_tier` marker even once the user's grant is spent — with no + // providers, but telling the client *why* AI is off. Ok(Json(free_config)) } else { Ok(Json(AIConfig::default())) diff --git a/frontend/src/lib/aiStore.ts b/frontend/src/lib/aiStore.ts index bd03f1baa4..7eb6f45324 100644 --- a/frontend/src/lib/aiStore.ts +++ b/frontend/src/lib/aiStore.ts @@ -1,6 +1,12 @@ import { writable, get } from 'svelte/store' import { workspaceAIClients } from './components/copilot/lib' -import { type AIProviderModel, type AIProvider, WorkspaceService, type AIConfig } from './gen' +import { + type AIProviderModel, + type AIProvider, + WorkspaceService, + type AIConfig, + type FreeTierInfo +} from './gen' import { aiUserDisabled, COPILOT_SESSION_MODEL_SETTING_NAME, @@ -39,6 +45,10 @@ export const copilotInfo = writable<{ customPrompts?: Record maxTokensPerModel?: Record webSearchEnabledProviders?: Partial> + // Set only when the workspace has no AI provider of its own and is running on + // Windmill's free tier. `exhausted` means the grant is spent: there is no model, but + // that is a different state from "never configured" and the UI must say so. + freeTier?: FreeTierInfo }>({ enabled: false, codeCompletionModel: undefined, @@ -142,7 +152,8 @@ export function setCopilotInfo(aiConfig: AIConfig) { aiModels: aiModels, customPrompts: aiConfig.custom_prompts ?? {}, maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}, - webSearchEnabledProviders + webSearchEnabledProviders, + freeTier: aiConfig.free_tier }) } else { copilotSessionModel.set(undefined) @@ -155,7 +166,10 @@ export function setCopilotInfo(aiConfig: AIConfig) { aiModels: [], customPrompts: {}, maxTokensPerModel: {}, - webSearchEnabledProviders: {} + webSearchEnabledProviders: {}, + // An exhausted free grant lands here — no providers, but the reason AI is off + // is "you used it up", not "you never set it up". + freeTier: aiConfig.free_tier }) } } diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 038751f4b6..468e5a1868 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -51,20 +51,26 @@ aiChatManager.scriptEditorOptions?.lang && !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)) ) + // A spent free grant is not an unconfigured workspace: AIChatDisplay already shows an + // in-thread banner naming the real cause and linking to the key settings, so the generic + // "enable Windmill AI" line would both duplicate it and misstate why the chat is off. + const freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true) const disabledMessage = $derived( forceDisabled ? forceDisabledMessage - : !hasCopilot - ? $aiUserDisabled - ? 'Windmill AI is disabled in your account settings' - : isAdmin - ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` - : 'Ask an admin to enable Windmill AI in this workspace to use this chat' - : aiChatManager.mode === AIMode.SCRIPT && - aiChatManager.scriptEditorOptions?.lang && - !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) - ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` - : '' + : freeTierExhausted + ? '' + : !hasCopilot + ? $aiUserDisabled + ? 'Windmill AI is disabled in your account settings' + : isAdmin + ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` + : 'Ask an admin to enable Windmill AI in this workspace to use this chat' + : aiChatManager.mode === AIMode.SCRIPT && + aiChatManager.scriptEditorOptions?.lang && + !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) + ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` + : '' ) const suggestions = [ diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 3845c20435..fe447e2f65 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -15,6 +15,7 @@ Hand, HistoryIcon, Hourglass, + KeyRound, MousePointer2, Plus, TextSelect, @@ -52,9 +53,16 @@ readDroppedEntries } from './files/fsAccess' import { sendUserToast } from '$lib/toast' + import Alert from '$lib/components/common/alert/Alert.svelte' + import { copilotInfo } from '$lib/aiStore' + import { base } from '$lib/base' const MAX_YOLO_TOOLTIP_TOOLS = 8 const aiChatManager = getAiChatManager() + + // The user spent their one-time free Windmill AI grant: there is no model left to send + // to, so say so in the thread itself rather than only failing on send. + let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true) // `label` is shown in the dropdown; `shortLabel` (when set) is shown in the // compact trigger pill to save horizontal space. type AutonomyModeOption = { label: string; shortLabel?: string; mode: AIAutonomyMode } @@ -479,6 +487,27 @@ ) +{#snippet freeTierExhaustedBanner()} +
+ +
+ + You have used all of your free Windmill AI tokens. Add your own API key to keep using AI. + + +
+
+
+{/snippet} +
script editor to modify selected lines. {/if} + {#if freeTierExhausted} +
+ {@render freeTierExhaustedBanner()} +
+ {/if} {/if} {#if messages.length > 0} @@ -613,6 +647,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> isLast={messageIndex === messages.length - 1} /> {/each} + {#if freeTierExhausted} + {@render freeTierExhaustedBanner()} + {/if} {#if showTypingIndicator}
= 80) + let capability = $derived( getReasoningCapability(providerModel.provider as AIProvider, providerModel.model) ) @@ -311,6 +318,13 @@ {#if effortLabel} · {effortLabel} {/if} + {#if freeTier && !freeTier.exhausted} + Free + {/if}
@@ -377,6 +391,42 @@
Not supported by this model
{/if} + + {#if freeTier} +
+ +
+
+ Free Windmill AI + {freeUsedPct}% used +
+
+
+
+
+ {freeTier.exhausted + ? 'Your free tokens are used up. Add your own API key to keep using AI.' + : 'One-time allowance. Add your own API key for unlimited use.'} +
+ {#if isAdmin} + + Add your own API key + + {/if} +
+ {/if} {/snippet} diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 0364bf66c8..2611739a97 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -20,7 +20,7 @@