Skip to content

Commit 24c832a

Browse files
committed
feat(aws-marketplace): bill a listing's LLM usage to AWS hourly through a metering ledger
1 parent 392dc44 commit 24c832a

13 files changed

Lines changed: 33942 additions & 7 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createLogger } from '@sim/logger'
2+
import { generateShortId } from '@sim/utils/id'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
6+
import { meterAwsMarketplaceUsage } from '@/lib/billing/aws-marketplace/metering'
7+
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
8+
import { runDetached } from '@/lib/core/utils/background'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
11+
const logger = createLogger('AwsMarketplaceMeterCron')
12+
13+
const LOCK_KEY = 'aws-marketplace-meter-lock'
14+
/** Outlasts a run comfortably; the schedule runs every 15 minutes. */
15+
const LOCK_TTL_SECONDS = 10 * 60
16+
17+
export const dynamic = 'force-dynamic'
18+
19+
/**
20+
* Records newly closed hours of usage owed through AWS Marketplace and sends AWS what it has not
21+
* accepted yet. Acknowledges the cron call at once and works in the background; a Redis lock keeps
22+
* runs from overlapping, and the ledger makes a replayed run harmless. A no-op on deployments
23+
* where no configured listing bills usage.
24+
*/
25+
export const GET = withRouteHandler(async (request: NextRequest) => {
26+
const authError = verifyCronAuth(request, 'AWS Marketplace metering')
27+
if (authError) {
28+
return authError
29+
}
30+
31+
if (!isAwsMarketplaceEnabled()) {
32+
return NextResponse.json({
33+
success: true,
34+
message: 'AWS Marketplace is not configured – skipped',
35+
status: 'skip',
36+
})
37+
}
38+
39+
const lockValue = generateShortId()
40+
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, {
41+
reclaimOnFailure: true,
42+
})
43+
if (!locked) {
44+
return NextResponse.json(
45+
{
46+
success: true,
47+
message: 'AWS Marketplace metering already in progress – skipped',
48+
status: 'skip',
49+
},
50+
{ status: 202 }
51+
)
52+
}
53+
54+
runDetached('aws-marketplace-meter', async () => {
55+
try {
56+
const summary = await meterAwsMarketplaceUsage()
57+
if (summary) logger.info('AWS Marketplace metering completed', { ...summary })
58+
} finally {
59+
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
60+
}
61+
})
62+
63+
return NextResponse.json(
64+
{ success: true, message: 'AWS Marketplace metering started', status: 'started' },
65+
{ status: 202 }
66+
)
67+
})

‎apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,28 @@ describe('linking a claimed license', () => {
743743
})
744744
})
745745

746+
it('starts the license usage at the first link and keeps it when the same organization links again', async () => {
747+
const fresh = await createClaimedLicense()
748+
entitlementsByLicense.set(fresh.licenseArn, [currentEntitlement()])
749+
const before = Date.now()
750+
751+
await link(ownerId, { licenseId: fresh.id, claimToken: fresh.token })
752+
753+
const linkedAt = (await readLicense(fresh.licenseArn))?.organizationLinkedAt
754+
expect(linkedAt?.getTime()).toBeGreaterThanOrEqual(before - 1000)
755+
756+
const firstLinkedAt = new Date(Date.now() - 5 * 60 * 60 * 1000)
757+
const relinked = await createClaimedLicense({
758+
organizationId,
759+
organizationLinkedAt: firstLinkedAt,
760+
})
761+
entitlementsByLicense.set(relinked.licenseArn, [currentEntitlement()])
762+
763+
await link(ownerId, { licenseId: relinked.id, claimToken: relinked.token })
764+
765+
expect((await readLicense(relinked.licenseArn))?.organizationLinkedAt).toEqual(firstLinkedAt)
766+
})
767+
746768
it('refuses to move a license already linked to another organization', async () => {
747769
const otherOwnerId = await createUser()
748770
const otherOrganizationId = await createOrganization([{ userId: otherOwnerId, role: 'owner' }])

0 commit comments

Comments
 (0)