Skip to content

Commit bb7bbea

Browse files
committed
fix(billing): withhold hosted Fal.ai video generation from free-plan payers
1 parent 99a029d commit bb7bbea

6 files changed

Lines changed: 186 additions & 14 deletions

File tree

‎apps/sim/lib/billing/core/chat-access.ts‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,21 @@ import { isFree } from '@/lib/billing/plan-helpers'
55
import type { UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
66
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
77

8-
const logger = createLogger('ChatAccess')
8+
const logger = createLogger('PaidAccess')
99

1010
/**
11-
* Whether a payer may use Chat (talking to Sim). Every turn spends hosted model credits, so a
12-
* hosted deployment that bills withholds Chat from a payer on the free plan — unless that payer
13-
* holds prepaid credits, which an admin grants to customers billed outside a subscription.
11+
* Whether a payer may use a feature a hosted deployment reserves for paying payers: Chat, and
12+
* hosted keys a tool marks `requiresPaidPlan`. A hosted deployment that bills withholds these from
13+
* a payer on the free plan, unless that payer holds prepaid credits, which an admin grants to
14+
* customers billed outside a subscription.
1415
*
1516
* The plan is the payer's entitled subscription (active or past due), so a canceled
1617
* plan reads as free; a blocked payer is refused by the usage check every caller runs next.
1718
*
18-
* This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled
19-
* job dispatch, Copilot key admission, and the workspace host context the composer reads, so the
20-
* composer never offers a send that admission will refuse. A balance that cannot be read admits
21-
* the payer: a database blip must not lock a paying customer out, and a free payer stays bounded
22-
* by its usage limit.
19+
* A balance that cannot be read admits the payer: a database blip must not lock a paying
20+
* customer out, and a free payer stays bounded by its usage limit.
2321
*/
24-
export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise<boolean> {
22+
export async function payerHasPaidAccess(payer: UsageUpgradePayer): Promise<boolean> {
2523
if (!isHosted || !isBillingEnabled) return true
2624
if (!isFree(payer.payerSubscription?.plan)) return true
2725
try {
@@ -31,10 +29,20 @@ export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise<bool
3129
)
3230
return balance > 0
3331
} catch (error) {
34-
logger.warn('Credit balance unavailable; admitting the payer to Chat', {
32+
logger.warn('Credit balance unavailable; admitting the payer', {
3533
billingEntity: payer.billingEntity,
3634
error: getErrorMessage(error),
3735
})
3836
return true
3937
}
4038
}
39+
40+
/**
41+
* Whether a payer may use Chat (talking to Sim): {@link payerHasPaidAccess}, since every turn
42+
* spends hosted model credits.
43+
*
44+
* This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled
45+
* job dispatch, Copilot key admission, and the workspace host context the composer reads, so the
46+
* composer never offers a send that admission will refuse.
47+
*/
48+
export const payerHasChatAccess = payerHasPaidAccess

‎apps/sim/tools/errors.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ export class HostedKeyRateLimitedError extends HttpError {
1717
}
1818
}
1919

20+
/**
21+
* The tool's hosted key is reserved for paying payers and this run's payer is on the free plan.
22+
* Carries a 402 so the run fails as the caller's plan, not as a Sim fault.
23+
*/
24+
export class HostedKeyRequiresPaidPlanError extends HttpError {
25+
readonly statusCode = 402
26+
27+
constructor(message: string) {
28+
super(message)
29+
this.name = 'HostedKeyRequiresPaidPlanError'
30+
}
31+
}
32+
2033
/** No hosted keys are configured or available for this provider. */
2134
export class HostedKeyUnavailableError extends HttpError {
2235
readonly statusCode = 503

‎apps/sim/tools/index.test.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ const {
111111
mockExecuteChatFunction,
112112
mockGetInternalToolOperationHandler,
113113
mockExecuteInternalToolOperation,
114+
mockPayerHasPaidAccess,
114115
} = vi.hoisted(() => ({
116+
mockPayerHasPaidAccess: vi.fn(),
115117
mockGetToolAsync: vi.fn(),
116118
mockRateLimiterFns: {
117119
acquireKey: vi.fn(),
@@ -173,6 +175,10 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
173175
getHostedKeyRateLimiter: () => mockRateLimiterFns,
174176
}))
175177

178+
vi.mock('@/lib/billing/core/chat-access', () => ({
179+
payerHasPaidAccess: mockPayerHasPaidAccess,
180+
}))
181+
176182
vi.mock('@/lib/uploads/contexts/workspace/file-manager', () => workspaceFileManagerMock)
177183

178184
vi.mock(
@@ -6551,6 +6557,97 @@ describe('Hosted Key Injection', () => {
65516557
}
65526558
})
65536559

6560+
describe('requiresPaidPlan', () => {
6561+
const PAID_ONLY_TOOL_ID = 'test_paid_only_hosting'
6562+
6563+
beforeEach(() => {
6564+
setEnvFlags({ isHosted: true, isBillingEnabled: true })
6565+
mockRateLimiterFns.acquireKey.mockResolvedValue({
6566+
success: true,
6567+
key: 'platform-fixture-key',
6568+
keyIndex: 0,
6569+
envVarName: 'TEST_HOSTED_KEY',
6570+
})
6571+
tools[PAID_ONLY_TOOL_ID] = {
6572+
id: PAID_ONLY_TOOL_ID,
6573+
name: 'Paid-only fixture',
6574+
description: 'Hosted key reserved for paying payers',
6575+
version: '1.0.0',
6576+
params: {},
6577+
request: { url: 'https://example.com/fixture', method: 'GET', headers: () => ({}) },
6578+
hosting: {
6579+
envKeyPrefix: 'TEST_HOSTED_KEY',
6580+
apiKeyParam: 'apiKey',
6581+
byokProviderId: 'falai',
6582+
requiresPaidPlan: true,
6583+
pricing: { type: 'per_request', cost: 0.5 },
6584+
rateLimit: { mode: 'per_request', requestsPerMinute: 100 },
6585+
},
6586+
transformResponse: async () => ({ success: true, output: {} }),
6587+
}
6588+
})
6589+
6590+
afterEach(() => {
6591+
Reflect.deleteProperty(tools, PAID_ONLY_TOOL_ID)
6592+
resetEnvFlagsMock()
6593+
})
6594+
6595+
async function runPaidOnlyTool() {
6596+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34' })
6597+
mockSecureFetchWithPinnedIP.mockResolvedValue({
6598+
ok: true,
6599+
status: 200,
6600+
statusText: 'OK',
6601+
headers: { get: () => null, toRecord: () => ({}) },
6602+
json: async () => ({ ok: true }),
6603+
text: async () => '{"ok":true}',
6604+
})
6605+
const request = mockSecureFetchWithPinnedIP
6606+
const result = await executeTool(
6607+
PAID_ONLY_TOOL_ID,
6608+
{},
6609+
{ executionContext: createToolExecutionContext({ userId: 'user-1' }) }
6610+
)
6611+
return { result, request }
6612+
}
6613+
6614+
it('refuses the hosted key to a payer without paid access before acquiring one', async () => {
6615+
mockGetBYOKKey.mockResolvedValue(null)
6616+
mockPayerHasPaidAccess.mockResolvedValue(false)
6617+
6618+
const { result, request } = await runPaidOnlyTool()
6619+
6620+
expect(result.success).toBe(false)
6621+
expect(result.error).toContain('available on paid plans')
6622+
expect(mockPayerHasPaidAccess).toHaveBeenCalledWith(TEST_BILLING_ATTRIBUTION)
6623+
expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled()
6624+
expect(request).not.toHaveBeenCalled()
6625+
})
6626+
6627+
it('grants the hosted key to a payer with paid access', async () => {
6628+
mockGetBYOKKey.mockResolvedValue(null)
6629+
mockPayerHasPaidAccess.mockResolvedValue(true)
6630+
6631+
const { result, request } = await runPaidOnlyTool()
6632+
6633+
expect(result.success).toBe(true)
6634+
expect(mockRateLimiterFns.acquireKey).toHaveBeenCalledTimes(1)
6635+
expect(request).toHaveBeenCalledTimes(1)
6636+
})
6637+
6638+
it("runs a free payer's own workspace key without consulting the plan", async () => {
6639+
mockGetBYOKKey.mockResolvedValue({ apiKey: 'workspace-own-key' })
6640+
mockPayerHasPaidAccess.mockResolvedValue(false)
6641+
6642+
const { result, request } = await runPaidOnlyTool()
6643+
6644+
expect(result.success).toBe(true)
6645+
expect(mockPayerHasPaidAccess).not.toHaveBeenCalled()
6646+
expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled()
6647+
expect(request).toHaveBeenCalledTimes(1)
6648+
})
6649+
})
6650+
65546651
it('should not inject hosted key when tool has no hosting config', async () => {
65556652
const mockTool = {
65566653
id: 'test_no_hosting',

‎apps/sim/tools/index.ts‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import type { InternalSandboxProfile } from '@/lib/auth/internal'
1313
import {
1414
BILLING_ATTRIBUTION_HEADER,
1515
type BillingAttributionSnapshot,
16+
resolveWorkspaceBillingPayer,
1617
serializeBillingAttributionHeader,
1718
} from '@/lib/billing/core/attribution'
18-
import { isHosted } from '@/lib/core/config/env-flags'
19+
import { payerHasPaidAccess } from '@/lib/billing/core/chat-access'
20+
import type { UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
21+
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
1922
import {
2023
findDatabaseQueryError,
2124
getUserFacingErrorMessage,
@@ -111,7 +114,11 @@ import {
111114
} from '@/executor/utils/resolved-secret-trace-registry'
112115
import type { ErrorInfo } from '@/tools/error-extractors'
113116
import { extractErrorMessage, redactErrorData } from '@/tools/error-extractors'
114-
import { HostedKeyRateLimitedError, HostedKeyUnavailableError } from '@/tools/errors'
117+
import {
118+
HostedKeyRateLimitedError,
119+
HostedKeyRequiresPaidPlanError,
120+
HostedKeyUnavailableError,
121+
} from '@/tools/errors'
115122
import {
116123
getOwnEnumerableDataEntries,
117124
prepareToolRequest,
@@ -547,6 +554,40 @@ async function registerAssistantCredentialSecrets(
547554
}
548555
}
549556

557+
/**
558+
* Refuses a `requiresPaidPlan` hosted key to a run whose payer lacks paid access. The payer is
559+
* the run's admitted attribution, or the workspace's payer for a surface that carries none; a
560+
* workspace without a payer is refused.
561+
*/
562+
async function assertPayerMayUseHostedKey(
563+
tool: ToolDefinition,
564+
workspaceId: string | undefined,
565+
billingAttribution: BillingAttributionSnapshot | undefined
566+
): Promise<void> {
567+
if (!isBillingEnabled) return
568+
569+
let payer: UsageUpgradePayer | null = billingAttribution ?? null
570+
if (!payer && workspaceId) {
571+
const workspacePayer = await resolveWorkspaceBillingPayer(workspaceId, {
572+
onMissing: 'return-null',
573+
})
574+
if (workspacePayer) {
575+
payer = {
576+
billingEntity: workspacePayer.organizationId
577+
? { type: 'organization', id: workspacePayer.organizationId }
578+
: { type: 'user', id: workspacePayer.billedAccountUserId },
579+
payerSubscription: workspacePayer.payerSubscription,
580+
}
581+
}
582+
}
583+
584+
if (payer && (await payerHasPaidAccess(payer))) return
585+
586+
throw new HostedKeyRequiresPaidPlanError(
587+
`${tool.name} with Sim's hosted key is available on paid plans. Upgrade your plan, or add your own API key under Settings > BYOK.`
588+
)
589+
}
590+
550591
/** Result from hosted key injection */
551592
interface HostedKeyInjectionResult {
552593
isUsingHostedKey: boolean
@@ -577,7 +618,10 @@ async function injectHostedKeyIfNeeded(
577618
return { isUsingHostedKey: false }
578619
}
579620

580-
const { workspaceId, userId, workflowId } = resolveToolScope(params, executionContext)
621+
const { workspaceId, userId, workflowId, billingAttribution } = resolveToolScope(
622+
params,
623+
executionContext
624+
)
581625

582626
if (byokProviderId && workspaceId) {
583627
const byokResult = await getBYOKKey(workspaceId, byokProviderId as BYOKProviderId, { signal })
@@ -588,6 +632,10 @@ async function injectHostedKeyIfNeeded(
588632
}
589633
}
590634

635+
if (tool.hosting.requiresPaidPlan) {
636+
await assertPayerMayUseHostedKey(tool, workspaceId, billingAttribution)
637+
}
638+
591639
const rateLimiter = getHostedKeyRateLimiter()
592640
const provider = byokProviderId || tool.id
593641
const billingActorId = workspaceId

‎apps/sim/tools/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,11 @@ export interface ToolHostingConfig<P = Record<string, unknown>> {
499499
apiKeyParam: string
500500
/** BYOK provider ID for workspace key lookup */
501501
byokProviderId?: BYOKProviderId
502+
/**
503+
* Withholds the hosted key from a free-plan payer without prepaid credits. A workspace BYOK key
504+
* or a key the user passes still runs; only Sim's own key is refused.
505+
*/
506+
requiresPaidPlan?: boolean
502507
/** Pricing when using hosted key */
503508
pricing: ToolHostingPricing<P>
504509
/** Hosted key rate limit configuration (required for hosted key distribution) */

‎apps/sim/tools/video/falai.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export const falaiVideoTool: InternalToolConfig<VideoParams, VideoResponse> = {
7171
envKeyPrefix: 'FALAI_API_KEY',
7272
apiKeyParam: 'apiKey',
7373
byokProviderId: 'falai',
74+
requiresPaidPlan: true,
7475
pricing: {
7576
type: 'custom',
7677
getCost: (_params, output) => {

0 commit comments

Comments
 (0)