Skip to content

Commit 7fea1a4

Browse files
committed
update
1 parent ab4e755 commit 7fea1a4

3 files changed

Lines changed: 45 additions & 60 deletions

File tree

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
88
mockCheckInternalApiKey,
9-
mockRecordUsage,
109
mockRecordCumulativeUsage,
1110
mockCheckAndBillOverageThreshold,
1211
mockCheckAndBillPayerOverageThreshold,
@@ -19,7 +18,6 @@ const {
1918
billingState,
2019
} = vi.hoisted(() => ({
2120
mockCheckInternalApiKey: vi.fn(),
22-
mockRecordUsage: vi.fn(),
2321
mockRecordCumulativeUsage: vi.fn(),
2422
mockCheckAndBillOverageThreshold: vi.fn(),
2523
mockCheckAndBillPayerOverageThreshold: vi.fn(),
@@ -59,7 +57,6 @@ vi.mock('@/lib/copilot/request/otel', () => ({
5957

6058
vi.mock('@/lib/billing/core/usage-log', () => ({
6159
CumulativeUsageContextMismatchError: MockCumulativeUsageContextMismatchError,
62-
recordUsage: mockRecordUsage,
6360
recordCumulativeUsage: mockRecordCumulativeUsage,
6461
}))
6562

@@ -150,13 +147,21 @@ const OLD_GO_OPAQUE_WORKSPACE_UPDATE_COST_BODY = {
150147
workspaceId: 'local-self-hosted-workspace',
151148
} as const
152149

150+
const KEYLESS_UPDATE_COST_BODY = {
151+
userId: 'user-1',
152+
cost: 0.5,
153+
model: 'gpt',
154+
inputTokens: 1,
155+
outputTokens: 2,
156+
source: 'copilot',
157+
} as const
158+
153159
describe('POST /api/billing/update-cost — workspaceId attribution', () => {
154160
beforeEach(() => {
155161
vi.clearAllMocks()
156162
billingState.isBillingEnabled = true
157163
billingState.isCopilotBillingProtocolRequired = false
158164
mockCheckInternalApiKey.mockReturnValue({ success: true })
159-
mockRecordUsage.mockResolvedValue(undefined)
160165
mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
161166
mockCheckAndBillOverageThreshold.mockResolvedValue(undefined)
162167
mockCheckAndBillPayerOverageThreshold.mockResolvedValue(undefined)
@@ -191,7 +196,6 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
191196
error: 'Invalid internal API key',
192197
})
193198
expect(mockCheckInternalApiKey).toHaveBeenCalledTimes(1)
194-
expect(mockRecordUsage).not.toHaveBeenCalled()
195199
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
196200
})
197201

@@ -218,7 +222,6 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
218222
data: { billingEnabled: false },
219223
})
220224
expect(mockCheckInternalApiKey).toHaveBeenCalledTimes(1)
221-
expect(mockRecordUsage).not.toHaveBeenCalled()
222225
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
223226
})
224227

@@ -232,6 +235,17 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
232235
).toBe(true)
233236
})
234237

238+
it('rejects billing-enabled callbacks without a stable idempotency key', async () => {
239+
const res = await POST(
240+
createMockRequest('POST', KEYLESS_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
241+
)
242+
243+
expect(res.status).toBe(400)
244+
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
245+
expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
246+
expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
247+
})
248+
235249
it('bills the routed workspace payer for the exact markerless hosted callback', async () => {
236250
const res = await POST(
237251
createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
@@ -565,7 +579,6 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
565579
code: 'DUPLICATE_BILLING_EVENT',
566580
})
567581
expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(2)
568-
expect(mockRecordUsage).not.toHaveBeenCalled()
569582
expect(mockResolveLegacyV0BillingAttribution).toHaveBeenCalledTimes(2)
570583
expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledTimes(2)
571584
expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenNthCalledWith(

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

Lines changed: 24 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
type CumulativeUsageContextField,
2323
CumulativeUsageContextMismatchError,
2424
recordCumulativeUsage,
25-
recordUsage,
2625
} from '@/lib/billing/core/usage-log'
2726
import {
2827
checkAndBillOverageThreshold,
@@ -293,57 +292,30 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
293292
workspaceId: resolvedWorkspaceId,
294293
})
295294

296-
// Go sends the request's CUMULATIVE cost, possibly more than once (a
297-
// mid-loop provider-error flush, then the recovered terminal flush, plus
298-
// abort-race duplicates). Record it as a monotonic top-up: one ledger row
299-
// per request holds the MAX cumulative and we bill only the delta, so
300-
// partial + complete flushes converge to the true total exactly once — no
301-
// under-billing on recovery, no over-billing on duplicates. When there is
302-
// no idempotency key (shouldn't happen for real requests) we fall back to a
303-
// plain append so cost is never silently dropped.
304-
let billed = true
305-
if (idempotencyKey) {
306-
const result = await recordCumulativeUsage({
307-
userId,
308-
workspaceId: resolvedWorkspaceId,
309-
...billingContext,
310-
source,
311-
model,
312-
cost,
313-
eventKey: `update-cost:${idempotencyKey}`,
314-
metadata: { inputTokens, outputTokens },
315-
})
316-
billed = result.billed
317-
logger.info(`[${requestId}] Cumulative cost top-up`, {
318-
userId,
319-
source,
320-
cumulativeCost: cost,
321-
billedDelta: result.delta,
322-
newTotal: result.total,
323-
billed: result.billed,
324-
})
325-
} else {
326-
await recordUsage({
327-
userId,
328-
workspaceId: resolvedWorkspaceId,
329-
...billingContext,
330-
entries: [
331-
{
332-
category: 'model',
333-
source,
334-
description: model,
335-
cost,
336-
sourceReference: requestId,
337-
metadata: { inputTokens, outputTokens },
338-
},
339-
],
340-
})
341-
logger.info(`[${requestId}] Recorded usage (no idempotency key)`, {
342-
userId,
343-
addedCost: cost,
344-
source,
345-
})
346-
}
295+
/**
296+
* Go sends cumulative cost across partial, terminal, and retry flushes.
297+
* Every accepted callback has a stable key, so the maximum cumulative cost
298+
* converges on one ledger event without underbilling or double-billing.
299+
*/
300+
const result = await recordCumulativeUsage({
301+
userId,
302+
workspaceId: resolvedWorkspaceId,
303+
...billingContext,
304+
source,
305+
model,
306+
cost,
307+
eventKey: `update-cost:${idempotencyKey}`,
308+
metadata: { inputTokens, outputTokens },
309+
})
310+
const billed = result.billed
311+
logger.info(`[${requestId}] Cumulative cost top-up`, {
312+
userId,
313+
source,
314+
cumulativeCost: cost,
315+
billedDelta: result.delta,
316+
newTotal: result.total,
317+
billed: result.billed,
318+
})
347319

348320
// Reconcile the payer's ledger-backed threshold after every cumulative
349321
// callback, including duplicate retries after a prior settlement failure.

apps/sim/lib/api/contracts/subscription.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const billingUpdateCostBodySchema = z.object({
2929
source: z
3030
.enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block'])
3131
.default('copilot'),
32-
idempotencyKey: z.string().min(1).optional(),
32+
idempotencyKey: z.string().min(1, 'Idempotency key is required'),
3333
/**
3434
* Originating workspace, used for org-workspace cost attribution on hosted
3535
* Sim. The value remains optional because self-hosted/headless callers may

0 commit comments

Comments
 (0)