Skip to content

Commit ce3e0b6

Browse files
authored
fix(billing): withhold hosted Fal.ai video generation from free-plan payers (#9741)
* fix(billing): withhold hosted Fal.ai video generation from free-plan payers * fix(billing): keep payerHasChatAccess a distinct export * fix(billing): deny the hosted key when a free payer's balance is unreadable
1 parent 37a8ad3 commit ce3e0b6

6 files changed

Lines changed: 263 additions & 16 deletions

File tree

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

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,30 @@ 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')
9+
10+
interface PaidAccessOptions {
11+
/**
12+
* The verdict for a free payer whose credit balance cannot be read. Chat admits, because a
13+
* database blip must not lock a paying customer out and every turn is bounded by the usage
14+
* check that follows. A hosted key denies, because nothing after acquisition bounds it.
15+
*/
16+
onBalanceUnavailable: 'admit' | 'deny'
17+
}
918

1019
/**
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.
20+
* Whether a payer may use a feature a hosted deployment reserves for paying payers: Chat, and
21+
* hosted keys a tool marks `requiresPaidPlan`. A hosted deployment that bills withholds these from
22+
* a payer on the free plan, unless that payer holds prepaid credits, which an admin grants to
23+
* customers billed outside a subscription.
1424
*
1525
* The plan is the payer's entitled subscription (active or past due), so a canceled
1626
* plan reads as free; a blocked payer is refused by the usage check every caller runs next.
17-
*
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.
2327
*/
24-
export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise<boolean> {
28+
export async function payerHasPaidAccess(
29+
payer: UsageUpgradePayer,
30+
options: PaidAccessOptions
31+
): Promise<boolean> {
2532
if (!isHosted || !isBillingEnabled) return true
2633
if (!isFree(payer.payerSubscription?.plan)) return true
2734
try {
@@ -31,10 +38,23 @@ export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise<bool
3138
)
3239
return balance > 0
3340
} catch (error) {
34-
logger.warn('Credit balance unavailable; admitting the payer to Chat', {
41+
logger.warn('Credit balance unavailable for a free payer', {
3542
billingEntity: payer.billingEntity,
43+
verdict: options.onBalanceUnavailable,
3644
error: getErrorMessage(error),
3745
})
38-
return true
46+
return options.onBalanceUnavailable === 'admit'
3947
}
4048
}
49+
50+
/**
51+
* Whether a payer may use Chat (talking to Sim): {@link payerHasPaidAccess}, since every turn
52+
* spends hosted model credits, admitting a payer whose balance cannot be read.
53+
*
54+
* This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled
55+
* job dispatch, Copilot key admission, and the workspace host context the composer reads, so the
56+
* composer never offers a send that admission will refuse.
57+
*/
58+
export function payerHasChatAccess(payer: UsageUpgradePayer): Promise<boolean> {
59+
return payerHasPaidAccess(payer, { onBalanceUnavailable: 'admit' })
60+
}

‎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: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { createSessionPrincipal } from '@sim/testing/factories/principal.factory
33
import { jsonResponse } from '@sim/testing/helpers/http'
44
import { apiKeyByokMock, apiKeyByokMockFns } from '@sim/testing/mocks/api-key-byok.mock'
55
import { authInternalMock, authInternalMockFns } from '@sim/testing/mocks/auth-internal.mock'
6+
import {
7+
billingAttributionMock,
8+
billingAttributionMockFns,
9+
} from '@sim/testing/mocks/billing-attribution.mock'
610
import { billingUsageLogMock } from '@sim/testing/mocks/billing-usage-log.mock'
711
import {
812
executorPrincipalMock,
@@ -111,7 +115,9 @@ const {
111115
mockExecuteChatFunction,
112116
mockGetInternalToolOperationHandler,
113117
mockExecuteInternalToolOperation,
118+
mockPayerHasPaidAccess,
114119
} = vi.hoisted(() => ({
120+
mockPayerHasPaidAccess: vi.fn(),
115121
mockGetToolAsync: vi.fn(),
116122
mockRateLimiterFns: {
117123
acquireKey: vi.fn(),
@@ -173,6 +179,12 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
173179
getHostedKeyRateLimiter: () => mockRateLimiterFns,
174180
}))
175181

182+
vi.mock('@/lib/billing/core/chat-access', () => ({
183+
payerHasPaidAccess: mockPayerHasPaidAccess,
184+
}))
185+
186+
vi.mock('@/lib/billing/core/attribution', () => billingAttributionMock)
187+
176188
vi.mock('@/lib/uploads/contexts/workspace/file-manager', () => workspaceFileManagerMock)
177189

178190
vi.mock(
@@ -442,6 +454,7 @@ import { createToolConfig, getTool } from '@/tools/utils'
442454
import { getToolAsync } from '@/tools/utils.server'
443455

444456
const mockGetBYOKKey = apiKeyByokMockFns.mockGetBYOKKey
457+
const mockResolveWorkspaceBillingPayer = billingAttributionMockFns.mockResolveWorkspaceBillingPayer
445458
const mockGenerateInternalToken = authInternalMockFns.mockGenerateInternalToken
446459
const mockCreateExecutorPrincipalFromExecutionContext =
447460
executorPrincipalMockFns.mockCreateExecutorPrincipalFromExecutionContext
@@ -6551,6 +6564,153 @@ describe('Hosted Key Injection', () => {
65516564
}
65526565
})
65536566

6567+
describe('requiresPaidPlan', () => {
6568+
const PAID_ONLY_TOOL_ID = 'test_paid_only_hosting'
6569+
6570+
beforeEach(() => {
6571+
setEnvFlags({ isHosted: true, isBillingEnabled: true })
6572+
mockRateLimiterFns.acquireKey.mockResolvedValue({
6573+
success: true,
6574+
key: 'platform-fixture-key',
6575+
keyIndex: 0,
6576+
envVarName: 'TEST_HOSTED_KEY',
6577+
})
6578+
tools[PAID_ONLY_TOOL_ID] = {
6579+
id: PAID_ONLY_TOOL_ID,
6580+
name: 'Paid-only fixture',
6581+
description: 'Hosted key reserved for paying payers',
6582+
version: '1.0.0',
6583+
params: {},
6584+
request: { url: 'https://example.com/fixture', method: 'GET', headers: () => ({}) },
6585+
hosting: {
6586+
envKeyPrefix: 'TEST_HOSTED_KEY',
6587+
apiKeyParam: 'apiKey',
6588+
byokProviderId: 'falai',
6589+
requiresPaidPlan: true,
6590+
pricing: { type: 'per_request', cost: 0.5 },
6591+
rateLimit: { mode: 'per_request', requestsPerMinute: 100 },
6592+
},
6593+
transformResponse: async () => ({ success: true, output: {} }),
6594+
}
6595+
})
6596+
6597+
afterEach(() => {
6598+
Reflect.deleteProperty(tools, PAID_ONLY_TOOL_ID)
6599+
resetEnvFlagsMock()
6600+
})
6601+
6602+
async function runPaidOnlyTool() {
6603+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34' })
6604+
mockSecureFetchWithPinnedIP.mockResolvedValue({
6605+
ok: true,
6606+
status: 200,
6607+
statusText: 'OK',
6608+
headers: { get: () => null, toRecord: () => ({}) },
6609+
json: async () => ({ ok: true }),
6610+
text: async () => '{"ok":true}',
6611+
})
6612+
const request = mockSecureFetchWithPinnedIP
6613+
const result = await executeTool(
6614+
PAID_ONLY_TOOL_ID,
6615+
{},
6616+
{ executionContext: createToolExecutionContext({ userId: 'user-1' }) }
6617+
)
6618+
return { result, request }
6619+
}
6620+
6621+
it('refuses the hosted key to a payer without paid access before acquiring one', async () => {
6622+
mockGetBYOKKey.mockResolvedValue(null)
6623+
mockPayerHasPaidAccess.mockResolvedValue(false)
6624+
6625+
const { result, request } = await runPaidOnlyTool()
6626+
6627+
expect(result.success).toBe(false)
6628+
expect(result.error).toContain('available on paid plans')
6629+
expect(mockPayerHasPaidAccess).toHaveBeenCalledWith(TEST_BILLING_ATTRIBUTION, {
6630+
onBalanceUnavailable: 'deny',
6631+
})
6632+
expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled()
6633+
expect(request).not.toHaveBeenCalled()
6634+
})
6635+
6636+
it('grants the hosted key to a payer with paid access', async () => {
6637+
mockGetBYOKKey.mockResolvedValue(null)
6638+
mockPayerHasPaidAccess.mockResolvedValue(true)
6639+
6640+
const { result, request } = await runPaidOnlyTool()
6641+
6642+
expect(result.success).toBe(true)
6643+
expect(mockRateLimiterFns.acquireKey).toHaveBeenCalledTimes(1)
6644+
expect(request).toHaveBeenCalledTimes(1)
6645+
})
6646+
6647+
async function runPaidOnlyToolWithoutAttribution() {
6648+
mockGetBYOKKey.mockResolvedValue(null)
6649+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34' })
6650+
mockSecureFetchWithPinnedIP.mockResolvedValue({
6651+
ok: true,
6652+
status: 200,
6653+
statusText: 'OK',
6654+
headers: { get: () => null, toRecord: () => ({}) },
6655+
json: async () => ({ ok: true }),
6656+
text: async () => '{"ok":true}',
6657+
})
6658+
const result = await executeTool(PAID_ONLY_TOOL_ID, {
6659+
_context: { workspaceId: 'workspace-without-run', userId: 'user-1' },
6660+
})
6661+
return { result, request: mockSecureFetchWithPinnedIP }
6662+
}
6663+
6664+
it('judges the workspace payer when the run carries no attribution', async () => {
6665+
mockResolveWorkspaceBillingPayer.mockResolvedValue({
6666+
billedAccountUserId: 'payer-1',
6667+
organizationId: 'org-1',
6668+
payerSubscription: { plan: 'team' },
6669+
})
6670+
mockPayerHasPaidAccess.mockResolvedValue(true)
6671+
6672+
const { result, request } = await runPaidOnlyToolWithoutAttribution()
6673+
6674+
expect(result.success).toBe(true)
6675+
expect(mockResolveWorkspaceBillingPayer).toHaveBeenCalledWith('workspace-without-run', {
6676+
onMissing: 'return-null',
6677+
})
6678+
expect(mockPayerHasPaidAccess).toHaveBeenCalledWith(
6679+
{
6680+
billingEntity: { type: 'organization', id: 'org-1' },
6681+
payerSubscription: { plan: 'team' },
6682+
},
6683+
{ onBalanceUnavailable: 'deny' }
6684+
)
6685+
expect(request).toHaveBeenCalledTimes(1)
6686+
})
6687+
6688+
it('refuses the hosted key when the workspace has no payer', async () => {
6689+
mockResolveWorkspaceBillingPayer.mockResolvedValue(null)
6690+
mockPayerHasPaidAccess.mockResolvedValue(true)
6691+
6692+
const { result, request } = await runPaidOnlyToolWithoutAttribution()
6693+
6694+
expect(result.success).toBe(false)
6695+
expect(result.error).toContain('available on paid plans')
6696+
expect(mockPayerHasPaidAccess).not.toHaveBeenCalled()
6697+
expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled()
6698+
expect(request).not.toHaveBeenCalled()
6699+
})
6700+
6701+
it("runs a free payer's own workspace key without consulting the plan", async () => {
6702+
mockGetBYOKKey.mockResolvedValue({ apiKey: 'workspace-own-key' })
6703+
mockPayerHasPaidAccess.mockResolvedValue(false)
6704+
6705+
const { result, request } = await runPaidOnlyTool()
6706+
6707+
expect(result.success).toBe(true)
6708+
expect(mockPayerHasPaidAccess).not.toHaveBeenCalled()
6709+
expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled()
6710+
expect(request).toHaveBeenCalledTimes(1)
6711+
})
6712+
})
6713+
65546714
it('should not inject hosted key when tool has no hosting config', async () => {
65556715
const mockTool = {
65566716
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, or a free payer whose credit balance cannot be read, 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, { onBalanceUnavailable: 'deny' }))) 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
@@ -500,6 +500,11 @@ export interface ToolHostingConfig<P = Record<string, unknown>> {
500500
apiKeyParam: string
501501
/** BYOK provider ID for workspace key lookup */
502502
byokProviderId?: BYOKProviderId
503+
/**
504+
* Withholds the hosted key from a free-plan payer without prepaid credits. A workspace BYOK key
505+
* or a key the user passes still runs; only Sim's own key is refused.
506+
*/
507+
requiresPaidPlan?: boolean
503508
/** Pricing when using hosted key */
504509
pricing: ToolHostingPricing<P>
505510
/** 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)