Skip to content

Commit 4d55764

Browse files
committed
fix(billing): read free payers fresh in the provider check and judge free by the admitted plan
1 parent 4e74dc0 commit 4d55764

8 files changed

Lines changed: 34 additions & 19 deletions

File tree

‎apps/sim/app/api/billing/update-cost/route.integration.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ function attributedCallback(
195195
})
196196
}
197197

198-
async function usageExceeded(response: Promise<Response>): Promise<boolean> {
198+
async function usageExceeded(response: ReturnType<typeof POST>): Promise<boolean> {
199199
const res = await response
200200
expect(res.status).toBe(200)
201201
return (await res.json()).usageExceeded

‎apps/sim/app/api/billing/update-cost/route.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,10 +1027,10 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
10271027

10281028
it('reads a free payer standing past its cached admission after every charge', async () => {
10291029
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
1030-
expect((await (await POST(attributedCallback())).json()).usageExceeded).toBe(false)
1030+
expect((await (await POST(attributedCallback(), {})).json()).usageExceeded).toBe(false)
10311031

10321032
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
1033-
await expect((await POST(attributedCallback())).json()).resolves.toMatchObject({
1033+
await expect((await POST(attributedCallback(), {})).json()).resolves.toMatchObject({
10341034
usageExceeded: true,
10351035
usageUpgrade: { action: 'upgrade_plan' },
10361036
})
@@ -1249,7 +1249,7 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
12491249
])('pauses a free run for %s, keeping the recorded charge', async (_case, answer) => {
12501250
answer()
12511251

1252-
const res = await POST(attributedCallback())
1252+
const res = await POST(attributedCallback(), {})
12531253

12541254
expect(res.status).toBe(200)
12551255
await expect(res.json()).resolves.toMatchObject({
@@ -1266,7 +1266,7 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
12661266
scope: 'payer',
12671267
})
12681268

1269-
expect((await (await POST(attributedCallback())).json()).usageExceeded).toBe(false)
1269+
expect((await (await POST(attributedCallback(), {})).json()).usageExceeded).toBe(false)
12701270
})
12711271

12721272
it('tells a member over the cap their organization set who can raise it', async () => {
@@ -1370,7 +1370,7 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
13701370
return { isExceeded: false }
13711371
})
13721372

1373-
const pending = POST(attributedCallback())
1373+
const pending = POST(attributedCallback(), {})
13741374
await vi.advanceTimersByTimeAsync(1000)
13751375
const res = await pending
13761376

‎apps/sim/app/api/copilot/api-keys/validate/route.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -661,12 +661,12 @@ describe('validation lifecycle purposes', () => {
661661
payerSubscriptionId: 'sub-account-org',
662662
}),
663663
}
664-
expect((await POST(request(body, paidHeaders))).status).toBe(200)
664+
expect((await POST(request(body, paidHeaders), {})).status).toBe(200)
665665
})
666666

667667
it('refuses a free direct-v1 continuation whose usage cannot be read', async () => {
668668
mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, unavailable: true })
669-
expect((await POST(request(body, directHeaders))).status).toBe(402)
669+
expect((await POST(request(body, directHeaders), {})).status).toBe(402)
670670
})
671671

672672
it.each([
@@ -803,24 +803,24 @@ describe('validation lifecycle purposes', () => {
803803
isExceeded: true,
804804
reason: 'usage_unavailable',
805805
})
806-
expect((await POST(request(body, paidHeaders))).status).toBe(200)
806+
expect((await POST(request(body, paidHeaders), {})).status).toBe(200)
807807
mockCheckAttributedUsageLimits.mockRejectedValue(new Error('ledger read timed out'))
808-
expect((await POST(request(body, paidHeaders))).status).toBe(200)
808+
expect((await POST(request(body, paidHeaders), {})).status).toBe(200)
809809
})
810810

811811
it('refuses a free payer polled continuation whose spend cannot be read, with the usage card', async () => {
812812
mockCheckAttributedUsageLimits.mockResolvedValue({
813813
isExceeded: true,
814814
reason: 'usage_unavailable',
815815
})
816-
const unavailable = await POST(request(body, attributedHeaders))
816+
const unavailable = await POST(request(body, attributedHeaders), {})
817817
expect(unavailable.status).toBe(402)
818818
await expect(unavailable.json()).resolves.toMatchObject({
819819
code: 'USAGE_LIMIT_EXCEEDED',
820820
usageUpgrade: { action: 'upgrade_plan' },
821821
})
822822
mockCheckAttributedUsageLimits.mockRejectedValue(new Error('ledger read timed out'))
823-
expect((await POST(request(body, attributedHeaders))).status).toBe(402)
823+
expect((await POST(request(body, attributedHeaders), {})).status).toBe(402)
824824
})
825825

826826
it('refuses a continuation over its usage limit with the card the worker writes', async () => {

‎apps/sim/app/api/copilot/api-keys/validate/route.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,9 @@ export const POST = withRouteHandler((req: NextRequest) =>
466466
? billing.attribution
467467
: verdict?.status === 'exceeded'
468468
? verdict.payer
469-
: undefined,
469+
: billing
470+
? { billingEntity: billing.decision.billingEntity, payerSubscription: null }
471+
: undefined,
470472
verdict?.status === 'exceeded' ? verdict.scope : undefined
471473
)
472474
return NextResponse.json<ValidateCopilotApiKeyUsageExceeded>(

‎apps/sim/lib/billing/core/mid-run-usage.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ const accountVerdictCache = new LRUCache<string, MidRunUsageVerdict>({
217217
* payer, in the gate's order: a blocked actor or payer first, then the payer's spend. The payer
218218
* is the one saved in the decision at admission, never re-selected from the actor's current
219219
* memberships, and the period judged is the one its charges land in, as for attributed runs.
220-
* Whether {@link MidRunUsageReadOptions.uncachedForFreePayer} applies is judged by the payer's
221-
* current subscription.
220+
* Whether {@link MidRunUsageReadOptions.uncachedForFreePayer} applies is judged by the plan the
221+
* run was admitted with, as {@link isFreeRunPayer} judges it.
222222
*/
223223
export async function readMidRunAccountUsageVerdict(
224224
decision: AccountBillingDecision,
@@ -253,7 +253,7 @@ export async function readMidRunAccountUsageVerdict(
253253
subscription?.status ?? '',
254254
subscription?.seats ?? '',
255255
].join(':')
256-
const uncached = options.uncachedForFreePayer === true && isFree(subscription?.plan)
256+
const uncached = options.uncachedForFreePayer === true && isFreeRunPayer(decision)
257257
const cached = uncached ? undefined : accountVerdictCache.get(key)
258258
if (cached) return cached
259259
const block = await checkAccountBillingBlocks(decision)

‎apps/sim/lib/compare/data/sim.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1152,7 +1152,7 @@ export const simProfile: CompetitorProfile = {
11521152
},
11531153
executionLimits: {
11541154
value:
1155-
'Plan-gated: synchronous runs time out at 5 minutes on the free plan and 50 minutes on paid plans; async runs default to 90 minutes, while Enterprise policy can be configured up to 7 days; concurrency ranges from 10 to 1,000 executions per billing account depending on plan',
1155+
'Plan-gated: synchronous runs time out at 5 minutes on the free plan and 50 minutes on paid plans; async runs default to 90 minutes, while Enterprise policy can be configured up to 7 days; concurrency ranges from 2 to 1,000 executions per billing account depending on plan',
11561156
detail:
11571157
'A direct async API request can shorten the account policy for that request, but cannot extend it. Concurrency limits are published in the platform cost docs, where the Enterprise limit is 1,000 by default and customizable. A run that exceeds its time limit is terminated and marked failed with a timeout error.',
11581158
shortValue: '5-50 min sync; async defaults to 90 min, Enterprise up to 7 days',

‎apps/sim/providers/run-usage-limit.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
* @vitest-environment node
33
*/
44
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
5+
import {
6+
billingAttributionMock,
7+
billingAttributionMockFns,
8+
} from '@sim/testing/mocks/billing-attribution.mock'
59
import { billingCoreMock } from '@sim/testing/mocks/billing-core.mock'
610
import {
711
billingUsageGateCacheMock,
@@ -17,6 +21,7 @@ vi.mock('@/providers/registry', () => ({
1721
getProviderExecutor: async () => ({ executeRequest: mockExecuteRequest }),
1822
}))
1923
vi.mock('@/lib/billing/core/usage-gate-cache', () => billingUsageGateCacheMock)
24+
vi.mock('@/lib/billing/core/attribution', () => billingAttributionMock)
2025
vi.mock('@/tools', () => toolsMock)
2126
vi.mock('@/lib/billing/core/billing', () => billingCoreMock)
2227
vi.mock('@/providers/cost-policy', async (importOriginal) => ({
@@ -60,6 +65,7 @@ import { providerFailureLogLevel } from '@/providers/streaming-tool-loop-shared'
6065
import { ProviderError, type ProviderRequest, type ProviderToolConfig } from '@/providers/types'
6166

6267
const { mockCheckExecutionUsageLimits } = billingUsageGateCacheMockFns
68+
const { mockCheckAttributedUsageLimits } = billingAttributionMockFns
6369
const { mockExecuteTool } = toolsMockFns
6470

6571
const attribution: BillingAttributionSnapshot = {
@@ -105,6 +111,11 @@ async function modelStep(request: ProviderRequest, input: number, output: number
105111
beforeEach(() => {
106112
setEnvFlags({ isHosted: true, isBillingEnabled: true })
107113
mockCheckExecutionUsageLimits.mockReset()
114+
// A free payer's mid-run read skips the cached gate; both answer from the same ledger.
115+
mockCheckAttributedUsageLimits.mockReset()
116+
mockCheckAttributedUsageLimits.mockImplementation((attribution) =>
117+
mockCheckExecutionUsageLimits(attribution)
118+
)
108119
mockCheckExecutionUsageLimits.mockResolvedValue({
109120
isExceeded: false,
110121
payerUsage: { currentUsage: 3, limit: 5 },

‎apps/sim/providers/run-usage-limit.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function recordModelStepCost(
6767
* and a free plan's spend past its limit is never billed. A paid payer's overshoot is billed as
6868
* overage, so its runs are left to admission and never read here.
6969
*
70-
* Reads nothing until the run has spent, then the cached execution gate. A gate that cannot
70+
* Reads nothing until the run has spent, then the payer's ledger. A gate that cannot
7171
* answer stops the run as if the limit were spent. A blocked account lets the run continue and
7272
* is not read again for {@link NEAR_LIMIT_USAGE_GATE_TTL_MS}: the next admission refuses it with
7373
* blocked-account copy rather than the upgrade card. The stop is not retried,
@@ -92,7 +92,9 @@ export async function assertRunWithinUsageLimit(
9292
return
9393
}
9494

95-
const verdict = await readMidRunUsageVerdict(attribution, unrecorded.total)
95+
const verdict = await readMidRunUsageVerdict(attribution, unrecorded.total, {
96+
uncachedForFreePayer: true,
97+
})
9698
// A run cancelled during the read keeps its cancellation; the provider call then aborts.
9799
if (verdict.status === 'within' || executionContext.abortSignal?.aborted) return
98100
if (verdict.status === 'blocked') {

0 commit comments

Comments
 (0)