[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) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-07-14 17:30:48 +02:00
parent c2411fde73
commit 76bd706112
16 changed files with 228 additions and 52 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
583a5c32eb9e04a7bb9a2a99cf7fb6cd4fe9f7f9
593ad8e171478758e95785f91c5d9548e09957bf
@@ -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,
+1 -1
View File
@@ -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)
+18
View File
@@ -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
+18
View File
@@ -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<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens_per_model: Option<HashMap<String, i32>>,
/// 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<FreeTierInfo>,
}
impl AIConfig {
+3 -2
View File
@@ -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()))
+17 -3
View File
@@ -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<string, string>
maxTokensPerModel?: Record<string, number>
webSearchEnabledProviders?: Partial<Record<AIProvider, boolean>>
// 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
})
}
}
@@ -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 = [
@@ -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 @@
)
</script>
{#snippet freeTierExhaustedBanner()}
<div class="my-2">
<Alert type="error" size="xs" title="Free Windmill AI used up">
<div class="flex flex-col items-start gap-2">
<span>
You have used all of your free Windmill AI tokens. Add your own API key to keep using AI.
</span>
<Button
size="xs2"
variant="default"
color="red"
startIcon={{ icon: KeyRound }}
href="{base}/workspace_settings?tab=ai"
>
Add your own API key
</Button>
</div>
</Alert>
</div>
{/snippet}
<!-- tabindex="-1": clicks on non-focusable chat content must move focus into
the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div
@@ -588,6 +617,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
script editor to modify selected lines.</span
>
{/if}
{#if freeTierExhausted}
<div class={wideLayout ? 'w-full max-w-3xl mx-auto px-7' : 'w-full max-w-2xl mx-auto px-3'}>
{@render freeTierExhaustedBanner()}
</div>
{/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}
<div
class={twMerge(
@@ -64,6 +64,7 @@ import { untrack } from 'svelte'
import { get } from 'svelte/store'
import { BROWSER } from 'esm-env'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import { copilotInfo, loadCopilot } from '$lib/aiStore'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { readDocsPageTool, searchDocsTool } from './docs/core'
import { TypewriterReveal } from './typewriterReveal'
@@ -267,6 +268,29 @@ function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean)
return appendWebSearchErrorHint(message, webSearchUnavailable)
}
/**
* A free-tier user can exhaust their grant mid-session: the request that spends the last
* of it succeeds, and the NEXT one is refused by the proxy. copilotInfo is only fetched on
* workspace load, so without this the client would keep believing it has budget until a
* page reload — the user would see only a toast, never the banner. Re-fetching the config
* after a failed send lets the server tell us the grant is gone, which flips
* `freeTier.exhausted` and reveals the banner.
*
* Scoped to users actually on the free tier and not already flagged, so an ordinary AI
* error (rate limit, network) costs no extra request. Deliberately keyed on that state
* rather than on matching the error text, which would break the moment the copy changes.
*/
async function refreshFreeTierStateAfterError(workspace: string | undefined) {
if (!workspace) return
const info = get(copilotInfo)
if (!info.freeTier || info.freeTier.exhausted) return
try {
await loadCopilot(workspace)
} catch (err) {
console.error('Failed to refresh copilot info after AI error', err)
}
}
export class AIChatManager {
contextManager = new ContextManager()
historyManager = new HistoryManager()
@@ -2395,6 +2419,7 @@ export class AIChatManager {
this.flagLastMessageAsError()
}
sendUserToast(getSendRequestErrorMessage(err, webSearchUnavailable), true)
await refreshFreeTierStateAfterError(this.operatingWorkspace)
} finally {
this.loading = false
// Turn teardown: cancel any in-flight reveal frame and drop leftover
@@ -45,6 +45,13 @@
)
let models = $derived($copilotInfo.aiModels)
// Free tier: the workspace has no key of its own and is spending Windmill's one-time
// grant. Label it so the user knows whose budget this is, and warn before it runs out
// rather than letting the grant die mid-task.
let freeTier = $derived($copilotInfo.freeTier)
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
let capability = $derived(
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
)
@@ -311,6 +318,13 @@
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if freeTier && !freeTier.exhausted}
<span
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'}">Free</span
>
{/if}
</span>
</Button>
</div>
@@ -377,6 +391,42 @@
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
</div>
{/if}
{#if freeTier}
<div class="my-1 border-t border-border-light"></div>
<!-- Not a melt item: informational, so keyboard navigation skips it. -->
<div class="px-3 pt-1 pb-1.5">
<div class="flex items-center justify-between">
<span class="text-2xs uppercase tracking-wide text-secondary">Free Windmill AI</span>
<span
class="text-2xs tabular-nums {freeRunningLow ? 'text-yellow-600' : 'text-secondary'}"
>{freeUsedPct}% used</span
>
</div>
<div class="mt-1.5 h-1 w-full rounded-full bg-surface-secondary overflow-hidden">
<div
class="h-full rounded-full {freeRunningLow
? 'bg-yellow-500'
: 'bg-surface-accent-primary'}"
style="width: {freeUsedPct}%"
></div>
</div>
<div class="mt-1 text-2xs text-tertiary">
{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.'}
</div>
{#if isAdmin}
<a
href={AI_SETTINGS_HREF}
target="_blank"
class="mt-1 inline-block text-2xs text-secondary hover:underline"
>
Add your own API key
</a>
{/if}
</div>
{/if}
</div>
{/snippet}
</DropdownV2>
@@ -20,7 +20,7 @@
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ArrowUp, ExternalLink, Globe2, PlugZap, Settings } from 'lucide-svelte'
import { ArrowUp, ExternalLink, Globe2, KeyRound, PlugZap, Settings } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte'
import { copilotInfo, copilotWorkspace, loadCopilot } from '$lib/aiStore'
@@ -48,6 +48,10 @@
// (unloaded) state doesn't flash the overlay while a provider is configured.
let disabled = $derived($copilotWorkspace === $workspaceStore && !$copilotInfo.enabled)
// Disabled because the user spent their free Windmill AI grant, not because AI was never
// set up — the two look identical otherwise, and the "configure AI" copy would be a lie.
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
let starting = $state(false)
async function start() {
if (disabled || starting || !value.trim()) return
@@ -112,12 +116,12 @@
<div class="w-full flex justify-center">
<div class="max-w-[40rem] grow relative group">
<p class="text-center font-regular text-3xl mb-4">Build with AI</p>
<div
class={disabled
? 'transition-[filter] group-hover:blur-sm pointer-events-none select-none'
: ''}
>
<p class="text-center font-regular text-3xl mb-4">Build with AI</p>
<!-- anchors the send button / model settings to the input, not to the whole
block — the row below would otherwise push them down -->
<div class="relative">
@@ -185,14 +189,18 @@
<div
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70 opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
>
<p class="text-sm text-secondary">No AI provider is configured</p>
<p class="text-sm text-secondary">
{freeTierExhausted
? 'You have used all of your free Windmill AI tokens'
: 'No AI provider is configured'}
</p>
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Settings }}
startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }}
href="{base}/workspace_settings?tab=ai"
>
Configure AI
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
</Button>
</div>
{/if}