Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/docs/content/docs/en/platform/costs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,18 @@ Team and Enterprise plans unlock shared workspaces that belong to your organizat
| **Max** | 300 | 2,500 |
| **Enterprise** | 600 | 5,000 |

Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
Rate limits follow the paid tier: Pro and Pro for Teams share the Pro limits, while Max and Max for Teams share the Max limits.

### Concurrent Executions

| Plan | Concurrent Executions |
|------|-----------------------|
| **Free** | 10 |
| **Pro** | 50 |
| **Max** | 200 |
| **Enterprise** | 1,000 by default (customizable) |

The concurrency limit applies per billing account. For organization-based plans, the limit is shared by all workspaces, members, and API keys under that organization. Teams plans follow their tier: Pro for Teams uses the Pro limit and Max for Teams uses the Max limit. An admitted async execution holds a slot while queued and running; synchronous executions hold one while running. Workflow-in-workflow blocks execute inside the parent run and do not consume another concurrency slot.

### File Storage

Expand All @@ -343,7 +354,7 @@ Max (individual) shares the same rate limits as team plans. Team plans (Pro or M
| **Max** | 500 GB |
| **Enterprise** | 500 GB (customizable) |

Team plans (Pro or Max for Teams) use 500 GB.
Storage follows the paid tier: Pro and Pro for Teams share the Pro limit, while Max and Max for Teams share the Max limit. Organization storage is pooled across the organization's workspaces.

### Run Time Limits

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,29 @@ Configure one provider — the mailer auto-detects in priority order: **Resend
|----------|-------------|
| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string |

## Limits

Self-hosted deployments (billing disabled) run without plan limits: no rate limits, no execution timeouts, no table or storage caps, and no retention-based data deletion. Each limit can be opted back in individually by explicitly setting its variable.

| Variable | Opts in | Suggested value |
|----------|---------|-----------------|
| `RATE_LIMIT_FREE_SYNC` | Sync executions per minute | `50` |
| `RATE_LIMIT_FREE_ASYNC` | Async executions per minute | `200` |
| `RATE_LIMIT_FREE_API_ENDPOINT` | v1 API endpoint requests per minute | `30` |
| `EXECUTION_TIMEOUT_FREE` | Sync execution timeout (seconds) | `300` |
| `EXECUTION_TIMEOUT_ASYNC_FREE` | Async execution timeout (seconds) | `5400` |
| `FREE_TABLES_LIMIT` | Max user tables per workspace | `5` |
| `FREE_TABLE_ROWS_LIMIT` | Max rows per user table | `50000` |
| `FREE_STORAGE_LIMIT_GB` | File storage quota (GB) | `5` |

<Callout type="info">
Without billing, every account resolves to the free tier, so only the free-tier variables apply. Setting one variable enforces only that limit — the rest stay unlimited.
</Callout>

<Callout type="warn">
Deployments installed with the Helm chart ship these variables preset in `app.envDefaults`, so chart-based installs keep enforcement unless those keys are removed or overridden.
</Callout>

## Example .env

```bash
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,15 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# Admin API (Optional - for self-hosted GitOps)
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces

# Limits (Optional - self-hosted). With billing disabled (BILLING_ENABLED unset), no plan
# limits are enforced. Explicitly setting a free-tier variable below opts that specific
# limit back in at the configured value.
# RATE_LIMIT_FREE_SYNC=50 # Sync executions per minute
# RATE_LIMIT_FREE_ASYNC=200 # Async executions per minute
# RATE_LIMIT_FREE_API_ENDPOINT=30 # v1 API endpoint requests per minute
# EXECUTION_TIMEOUT_FREE=300 # Sync execution timeout in seconds
# EXECUTION_TIMEOUT_ASYNC_FREE=5400 # Async execution timeout in seconds
# FREE_TABLES_LIMIT=5 # Max user tables per workspace
# FREE_TABLE_ROWS_LIMIT=50000 # Max rows per user table
# FREE_STORAGE_LIMIT_GB=5 # File storage quota in GB
29 changes: 20 additions & 9 deletions apps/sim/app/api/mcp/tools/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,26 @@ export const POST = withRouteHandler(
}

let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const result = await Promise.race([
mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error('Tool execution timeout')),
executionTimeout
)
}),
]).finally(() => {
const executePromise = mcpService.executeTool(
userId,
serverId,
toolCall,
workspaceId,
extraHeaders
)
// A zero timeout means "no timeout" (billing-disabled deployments).
const result = await (executionTimeout > 0
? Promise.race([
executePromise,
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error('Tool execution timeout')),
executionTimeout
)
}),
])
: executePromise
).finally(() => {
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DEFAULT_BILLING_CONCURRENCY_LIMITS } from '@/lib/billing/concurrency-defaults'
import {
CREDIT_TIERS,
CREDITS_PER_DOLLAR,
Expand Down Expand Up @@ -116,6 +117,20 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
},
],
},
{
title: 'Execution concurrency',
rows: [
{
label: 'Concurrent executions',
values: [
DEFAULT_BILLING_CONCURRENCY_LIMITS.free.toLocaleString('en-US'),
DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US'),
DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US'),
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.enterprise.toLocaleString('en-US')} (customizable)`,
],
},
],
},
{
title: 'Rate limits (runs/min)',
rows: [
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DEFAULT_BILLING_CONCURRENCY_LIMITS } from '@/lib/billing/concurrency-defaults'

/**
* Config for a plan's top-level credit stats.
* When `credits` is omitted the credits/refresh block is not rendered.
Expand Down Expand Up @@ -25,20 +27,23 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
}

export const PRO_PLAN_FEATURES: readonly string[] = [
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Deploy workflows as APIs',
'Extended run timeouts',
'More storage & tables',
]

export const MAX_PLAN_FEATURES: readonly string[] = [
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Sim Mailer & KB Live Sync',
'Highest rate limits',
'Expanded storage & tables',
]

export const ENTERPRISE_PLAN_FEATURES: readonly string[] = [
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.enterprise.toLocaleString('en-US')} concurrent executions, customizable`,
'Custom limits & infrastructure',
'SSO & SOC2 compliance',
'Access control & self-hosting',
Expand Down
24 changes: 22 additions & 2 deletions apps/sim/lib/admin/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
getOrganizationUsageLimitFallbackDollars,
getTeamOrganizationEconomics,
} from '@/lib/admin/organization-economics'
import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults'
import { getBillingConcurrencyLimit } from '@/lib/billing/concurrency-limits'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
Expand Down Expand Up @@ -190,6 +192,13 @@ function buildDashboardOrganizationSummary({
latestSubscription?.plan === 'enterprise'
? Math.max(0, Math.round(metadataNumber(metadata, 'seats') ?? 0))
: memberCount
const concurrencyLimit =
latestSubscription?.plan === 'enterprise'
? getBillingConcurrencyLimit(
latestSubscription.plan,
parseBillingConcurrencyLimit(metadata.concurrencyLimit)
)
: null

return {
id: org.id,
Expand All @@ -202,6 +211,7 @@ function buildDashboardOrganizationSummary({
memberCount,
externalCollaboratorCount,
seats,
concurrencyLimit,
includedMonthlyDollars,
usageLimitDollars,
effectiveUsageLimitDollars,
Expand Down Expand Up @@ -547,7 +557,11 @@ export async function updateDashboardEnterpriseSeats(

export async function updateDashboardOrganizationLimits(
organizationId: string,
values: { includedMonthlyDollars?: number; usageLimitDollars?: number },
values: {
includedMonthlyDollars?: number
usageLimitDollars?: number
concurrencyLimit?: number | null
},
actor: AdminMutationActor
) {
await db.transaction(async (tx) => {
Expand All @@ -574,6 +588,9 @@ export async function updateDashboardOrganizationLimits(
if (values.includedMonthlyDollars !== undefined && subscriptionRow?.plan !== 'enterprise') {
throw new Error('Included allowance is editable only for Enterprise organizations')
}
if (values.concurrencyLimit !== undefined && subscriptionRow?.plan !== 'enterprise') {
throw new Error('Concurrency is editable only for Enterprise organizations')
}

if (subscriptionRow?.plan === 'enterprise') {
if (!hasPaidSubscriptionStatus(subscriptionRow.status)) {
Expand All @@ -598,6 +615,9 @@ export async function updateDashboardOrganizationLimits(
...current,
includedMonthlyCredits: included,
usageLimitCredits: configuredUsageLimit,
...(values.concurrencyLimit !== undefined
? { concurrencyLimit: values.concurrencyLimit }
: {}),
}
},
})
Expand Down Expand Up @@ -650,7 +670,7 @@ export async function updateDashboardOrganizationLimits(
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: 'Admin updated organization credit limits',
description: 'Admin updated organization limits',
metadata: values,
})
}
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { describe, expect, it } from 'vitest'
import {
adminDashboardBalanceGrantBodySchema,
adminDashboardIssueEnterpriseBodySchema,
adminDashboardLimitsBodySchema,
adminDashboardOrganizationSummarySchema,
adminDashboardUpdateMemberBodySchema,
} from '@/lib/api/contracts/v1/admin/dashboard'
Expand Down Expand Up @@ -55,6 +57,7 @@ describe('admin dashboard credit grant contract', () => {
memberCount: 0,
externalCollaboratorCount: 0,
seats: 0,
concurrencyLimit: null,
includedMonthlyDollars: 0,
usageLimitDollars: 0.001,
effectiveUsageLimitDollars: 0.001,
Expand All @@ -64,4 +67,33 @@ describe('admin dashboard credit grant contract', () => {
}).success
).toBe(true)
})

it('accepts positive integer Enterprise concurrency limits', () => {
expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 1250 }).success).toBe(true)
expect(
adminDashboardIssueEnterpriseBodySchema.safeParse({
ownerUserId: 'owner-1',
monthlyInvoiceAmountUsd: 500,
includedMonthlyDollars: 500,
seats: 10,
concurrencyLimit: 1250,
pausePaymentCollection: true,
}).success
).toBe(true)
expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 0 }).success).toBe(false)
expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 1.5 }).success).toBe(false)
})

it('accepts null to restore the deployment-wide Enterprise concurrency default', () => {
expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: null }).success).toBe(true)
expect(
adminDashboardIssueEnterpriseBodySchema.safeParse({
ownerUserId: 'owner-1',
monthlyInvoiceAmountUsd: 500,
includedMonthlyDollars: 500,
seats: 10,
concurrencyLimit: null,
}).success
).toBe(false)
})
})
18 changes: 17 additions & 1 deletion apps/sim/lib/api/contracts/v1/admin/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
adminV1QueryStringSchema,
adminV1SingleResponseSchema,
} from '@/lib/api/contracts/v1/admin/shared'
import { MAX_BILLING_CONCURRENCY_LIMIT } from '@/lib/billing/concurrency-defaults'

const dollarAmountSchema = z
.number()
Expand Down Expand Up @@ -40,6 +41,8 @@ export const adminDashboardProvisioningSchema = z.object({
includedMonthlyDollars: creditAlignedDollarAmountSchema,
usageLimitDollars: creditAlignedDollarAmountSchema,
seats: z.number().int().positive(),
concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT),
pausePaymentCollection: z.boolean(),
stripeSubscriptionId: z.string().nullable(),
error: z.string().nullable(),
createdAt: z.string(),
Expand All @@ -57,6 +60,7 @@ export const adminDashboardOrganizationSummarySchema = z.object({
memberCount: z.number().int().min(0),
externalCollaboratorCount: z.number().int().min(0),
seats: z.number().int().min(0),
concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).nullable(),
includedMonthlyDollars: dollarAmountSchema,
usageLimitDollars: dollarAmountSchema,
effectiveUsageLimitDollars: dollarAmountSchema,
Expand Down Expand Up @@ -111,6 +115,8 @@ export const adminDashboardIssueEnterpriseBodySchema = z.object({
includedMonthlyDollars: creditAlignedDollarAmountSchema,
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
seats: z.number().int().positive().max(100_000),
concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).optional(),
pausePaymentCollection: z.boolean().optional(),
})

export const adminDashboardSeatsBodySchema = z.object({
Expand All @@ -121,9 +127,19 @@ export const adminDashboardLimitsBodySchema = z
.object({
includedMonthlyDollars: creditAlignedDollarAmountSchema.optional(),
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
concurrencyLimit: z
.number()
.int()
.positive()
.max(MAX_BILLING_CONCURRENCY_LIMIT)
.nullable()
.optional(),
})
.refine(
(value) => value.includedMonthlyDollars !== undefined || value.usageLimitDollars !== undefined,
(value) =>
value.includedMonthlyDollars !== undefined ||
value.usageLimitDollars !== undefined ||
value.concurrencyLimit !== undefined,
{ error: 'At least one limit must be provided' }
)

Expand Down
Loading
Loading