create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,699 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file usage-metering.ts
|
|
3
|
-
* @description Usage-metering write path — cost → markup → write (ADR 0009
|
|
4
|
-
* D3 service-layer assembler; spec docs/specs/3705-s1-usage-metering-service-spec.md).
|
|
5
|
-
*
|
|
6
|
-
* The AILK analog of working-theory's `UsageTracker.logEvent`: given a
|
|
7
|
-
* completed AI call's token breakdown + model, compute the provider cost
|
|
8
|
-
* (token counts × pricing, with an optional 2x extended-context surcharge),
|
|
9
|
-
* apply a hierarchical markup (model → provider → org → 20% system default),
|
|
10
|
-
* write the `UsageEvent` (the three attribution portions initialized to 0 —
|
|
11
|
-
* populated later by the credit waterfall, P3/P4), then upsert the
|
|
12
|
-
* `UsagePeriod` tier aggregate. Mirrors `lead-routing.ts`'s shape:
|
|
13
|
-
* discriminated `{ ok } | { ok: false, error }` result, no throw on the
|
|
14
|
-
* expected paths.
|
|
15
|
-
*
|
|
16
|
-
* This is a MONEY-WRITING path. Four invariants hold it (security review of
|
|
17
|
-
* PR #3727 — findings H3/H4/H5, M1–M5):
|
|
18
|
-
*
|
|
19
|
-
* 1. ATOMIC (H3). The `UsageEvent` insert and the `UsagePeriod` upsert run
|
|
20
|
-
* in ONE `$transaction`. Either both land or neither does — so a failed
|
|
21
|
-
* upsert (incl. the Prisma `upsert` P2002 race on a new period) can never
|
|
22
|
-
* strand a persisted event that the caller's retry would then double-bill.
|
|
23
|
-
* Every `ok: false` therefore carries `persisted: false`: nothing was
|
|
24
|
-
* written, a retry is always safe.
|
|
25
|
-
* 2. VALIDATED AT THE BOUNDARY (H4/M1). Every caller-supplied number is
|
|
26
|
-
* parsed by `recordUsageEventSchema` before it reaches the cost math or
|
|
27
|
-
* the DB — a negative count (which would DECREMENT the org's metered
|
|
28
|
-
* spend), a `NaN` (which Postgres `double precision` accepts, permanently
|
|
29
|
-
* poisoning the aggregate), a non-integer, or an absurd magnitude is
|
|
30
|
-
* rejected as `invalid_input`. `totalTokens` is DERIVED server-side from
|
|
31
|
-
* the buckets, never trusted from the caller (M1 — it alone drives the
|
|
32
|
-
* quota columns).
|
|
33
|
-
* 3. TYPED TENANT GRAIN (H5). The input carries a `TenantContext` whose ids
|
|
34
|
-
* are the branded `Id<>` types, so the type system forces callers through
|
|
35
|
-
* `resolveActiveTenant` (which proves session ↔ workspace ↔ org ↔
|
|
36
|
-
* Membership). `organizationId` keys BOTH the markup lookup AND the spend
|
|
37
|
-
* increment, so a caller-supplied string here bills the wrong tenant. The
|
|
38
|
-
* zod schema re-checks the id grammar at runtime, so a cast that smuggles
|
|
39
|
-
* a workspace id into the organization slot is rejected too.
|
|
40
|
-
* 4. DB-SOURCED NUMBERS ARE RANGE-CHECKED (M2/M3). Pricing rates, markup
|
|
41
|
-
* percents, and the model tier come from operator-editable rows; each is
|
|
42
|
-
* validated (finite, non-negative, in range / in the tier union) before it
|
|
43
|
-
* can reach the money math. A null markup column (`Number(null) === 0`)
|
|
44
|
-
* no longer silently zeroes revenue.
|
|
45
|
-
*
|
|
46
|
-
* PII boundary: only ids (organizationId/workspaceId/userId), provider/model
|
|
47
|
-
* names, and computed costs ever appear in logs or in a returned error. Never
|
|
48
|
-
* token content. Raw DB error text is logged server-side and NEVER returned —
|
|
49
|
-
* Prisma validation errors embed the full `data` argument (M5).
|
|
50
|
-
*
|
|
51
|
-
* Schema note: `UsageEvent`/`UsagePeriod` (database/content, #3709) and
|
|
52
|
-
* `LlmModel`/`LlmModelPricing`/`AiMarkupConfig` (database/config, #3710) are
|
|
53
|
-
* the FEAT-047 P1 models this P2.S1 service is written against — they are not
|
|
54
|
-
* yet in the generated Prisma client this package compiles against (P1 has
|
|
55
|
-
* not merged). `db` below is `prisma` structurally cast to the delegate shape
|
|
56
|
-
* this file needs; once P1 merges and the client regenerates, the real
|
|
57
|
-
* client's delegates satisfy this shape for free — no code change required.
|
|
58
|
-
* Field/constraint names (esp. the `UsagePeriod` compound-unique key) are the
|
|
59
|
-
* spec's best-effort assumption and should be reconciled against P1 as merged
|
|
60
|
-
* (spec §5.3).
|
|
61
|
-
*/
|
|
62
|
-
|
|
63
|
-
import { zOrganizationId, zUserId, zWorkspaceId } from "@working-theory/ids";
|
|
64
|
-
import { z } from "zod";
|
|
65
|
-
|
|
66
|
-
import { prisma } from "../lib/prisma.js";
|
|
67
|
-
|
|
68
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
export type ModelTier = "BASE" | "PREMIUM" | "FRONTIER";
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The tenant grain this service bills against (#5182).
|
|
74
|
-
*
|
|
75
|
-
* Previously `import type { TenantContext } from "./tenant-context.js"` — a
|
|
76
|
-
* multi-tenant SLICE file the OSS strip removes, which makes a type-only import
|
|
77
|
-
* of it a TS2307 on the public tree exactly as a value import would be (spec
|
|
78
|
-
* D1). There is no non-slice home for that type: `packages/observability`'s
|
|
79
|
-
* same-named interface is a telemetry attribute bag, not the branded auth
|
|
80
|
-
* grain, and reusing it would be a false friend.
|
|
81
|
-
*
|
|
82
|
-
* So the type is derived from this file's OWN runtime contract instead, which
|
|
83
|
-
* is strictly more accurate than the import was. `zTenantContext` below already
|
|
84
|
-
* re-validates exactly these three branded ids; the slice type's fourth member
|
|
85
|
-
* (`role`) was never read here and was never re-checked at runtime, so the
|
|
86
|
-
* declared input type and the validator now agree rather than diverge.
|
|
87
|
-
*/
|
|
88
|
-
type BillableTenant = z.infer<typeof zTenantContext>;
|
|
89
|
-
|
|
90
|
-
export interface RecordUsageEventInput {
|
|
91
|
-
/**
|
|
92
|
-
* The resolved tenant grain (H5) — produced by `resolveActiveTenant`, never
|
|
93
|
-
* assembled from request-supplied strings. `organizationId` keys the markup
|
|
94
|
-
* lookup AND the spend increment, so this is the tenant that gets billed.
|
|
95
|
-
*/
|
|
96
|
-
tenant: BillableTenant;
|
|
97
|
-
provider: string;
|
|
98
|
-
model: string;
|
|
99
|
-
inputTokensCacheWrite?: number;
|
|
100
|
-
inputTokensNoCacheWrite: number;
|
|
101
|
-
cacheReadTokens?: number;
|
|
102
|
-
outputTokens: number;
|
|
103
|
-
thinkingTokens?: number;
|
|
104
|
-
/** Actual conversation context size; omitted → 0 → extended pricing skipped ("don't guess"). */
|
|
105
|
-
contextWindowSize?: number;
|
|
106
|
-
/** Drives downstream aggregation filtering (P2.S2); the write itself never branches on it. */
|
|
107
|
-
completionStatus?: string;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export type RecordUsageErrorKind = "invalid_input" | "write_failed";
|
|
111
|
-
|
|
112
|
-
export type RecordUsageResult =
|
|
113
|
-
| { ok: true; eventId: string; billedCostUsd: number }
|
|
114
|
-
| {
|
|
115
|
-
ok: false;
|
|
116
|
-
error: {
|
|
117
|
-
kind: RecordUsageErrorKind;
|
|
118
|
-
message: string;
|
|
119
|
-
/**
|
|
120
|
-
* ALWAYS `false` (H3). The event + aggregate writes are one
|
|
121
|
-
* transaction, so a failure leaves NOTHING persisted — a caller may
|
|
122
|
-
* always retry without risking a double-bill. If this path ever gains
|
|
123
|
-
* a non-atomic branch, this field becomes a union and callers must
|
|
124
|
-
* branch on it.
|
|
125
|
-
*/
|
|
126
|
-
persisted: false;
|
|
127
|
-
};
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
/** The pure cost-math input — provider pricing for one (provider, model) pair. */
|
|
131
|
-
export interface ModelPricing {
|
|
132
|
-
inputPricePerMillion: number;
|
|
133
|
-
outputPricePerMillion: number;
|
|
134
|
-
cachedInputPricePerMillion?: number | null;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ─── Boundary validation (H4/M1) ──────────────────────────────────────────────
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Sane magnitude ceilings. These are ABSURDITY guards, not business limits —
|
|
141
|
-
* they exist so a corrupt/hostile caller cannot drive the cost math to
|
|
142
|
-
* `Infinity` (which Postgres `double precision` accepts, poisoning the
|
|
143
|
-
* aggregate). The largest real context window today is ~10M tokens.
|
|
144
|
-
*/
|
|
145
|
-
const MAX_TOKENS_PER_EVENT = 100_000_000;
|
|
146
|
-
const MAX_CONTEXT_WINDOW = 100_000_000;
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* A token bucket: finite, integral, non-negative, bounded. `z.number().int()`
|
|
150
|
-
* rejects `NaN` and `±Infinity` (neither is an integer) — the H4 poison path.
|
|
151
|
-
*/
|
|
152
|
-
const zTokenCount = z
|
|
153
|
-
.number()
|
|
154
|
-
.int("token counts must be integers")
|
|
155
|
-
.min(0, "token counts cannot be negative")
|
|
156
|
-
.max(MAX_TOKENS_PER_EVENT, "token count exceeds the per-event maximum");
|
|
157
|
-
|
|
158
|
-
/** The branded tenant grain (H5) — re-checked at runtime, not merely typed. */
|
|
159
|
-
const zTenantContext = z.object({
|
|
160
|
-
userId: zUserId,
|
|
161
|
-
organizationId: zOrganizationId,
|
|
162
|
-
workspaceId: zWorkspaceId,
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const recordUsageEventSchema = z.object({
|
|
166
|
-
tenant: zTenantContext,
|
|
167
|
-
provider: z.string().min(1).max(128),
|
|
168
|
-
model: z.string().min(1).max(128),
|
|
169
|
-
inputTokensCacheWrite: zTokenCount.optional(),
|
|
170
|
-
inputTokensNoCacheWrite: zTokenCount,
|
|
171
|
-
cacheReadTokens: zTokenCount.optional(),
|
|
172
|
-
outputTokens: zTokenCount,
|
|
173
|
-
thinkingTokens: zTokenCount.optional(),
|
|
174
|
-
contextWindowSize: z
|
|
175
|
-
.number()
|
|
176
|
-
.int("contextWindowSize must be an integer")
|
|
177
|
-
.min(0, "contextWindowSize cannot be negative")
|
|
178
|
-
.max(MAX_CONTEXT_WINDOW)
|
|
179
|
-
.optional(),
|
|
180
|
-
completionStatus: z.string().min(1).max(64).optional(),
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// ─── Structural delegate shape for the not-yet-generated FEAT-047 models ───────
|
|
184
|
-
// (See the schema note above — reconcile against P1/P1.S2 as merged.)
|
|
185
|
-
|
|
186
|
-
interface UsageEventCreateData {
|
|
187
|
-
organizationId: string;
|
|
188
|
-
workspaceId: string;
|
|
189
|
-
userId: string;
|
|
190
|
-
provider: string;
|
|
191
|
-
model: string;
|
|
192
|
-
inputTokensCacheWrite: number;
|
|
193
|
-
inputTokensNoCacheWrite: number;
|
|
194
|
-
cacheReadTokens: number;
|
|
195
|
-
outputTokens: number;
|
|
196
|
-
thinkingTokens: number;
|
|
197
|
-
totalTokens: number;
|
|
198
|
-
providerCostUsd: number;
|
|
199
|
-
markupPercent: number;
|
|
200
|
-
billedCostUsd: number;
|
|
201
|
-
modelTier: ModelTier;
|
|
202
|
-
wasAutoModel: boolean;
|
|
203
|
-
includedPortionCents: number;
|
|
204
|
-
creditBalancePortionCents: number;
|
|
205
|
-
onDemandPortionCents: number;
|
|
206
|
-
completionStatus: string;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
interface UsagePeriodUpsertData {
|
|
210
|
-
organizationId: string;
|
|
211
|
-
periodStart: Date;
|
|
212
|
-
periodEnd: Date;
|
|
213
|
-
tokensConsumed: number;
|
|
214
|
-
usageSpendCents: number;
|
|
215
|
-
baseTokensConsumed?: number;
|
|
216
|
-
premiumTokensConsumed?: number;
|
|
217
|
-
frontierTokensConsumed?: number;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
interface LlmModelRow {
|
|
221
|
-
tier: ModelTier;
|
|
222
|
-
isAutoModel: boolean;
|
|
223
|
-
chargeExtendedContextAt2x: boolean;
|
|
224
|
-
consumptionMultiplier: number | string;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
interface LlmModelPricingRow {
|
|
228
|
-
inputPricePerMillion: number | string;
|
|
229
|
-
outputPricePerMillion: number | string;
|
|
230
|
-
cachedInputPricePerMillion: number | string | null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
interface AiMarkupConfigRow {
|
|
234
|
-
markupPercent: number | string;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* The two write delegates, as seen INSIDE the transaction (H3). `recordUsageEvent`
|
|
239
|
-
* reaches the `UsageEvent` / `UsagePeriod` writers ONLY through the `$transaction`
|
|
240
|
-
* callback, never off the base client — which is what makes the pair atomic.
|
|
241
|
-
*/
|
|
242
|
-
export interface UsageWriteClient {
|
|
243
|
-
usageEvent: {
|
|
244
|
-
create(args: {
|
|
245
|
-
data: UsageEventCreateData;
|
|
246
|
-
select: { id: true };
|
|
247
|
-
}): Promise<{ id: string }>;
|
|
248
|
-
};
|
|
249
|
-
usagePeriod: {
|
|
250
|
-
upsert(args: {
|
|
251
|
-
where: {
|
|
252
|
-
organizationId_periodStart: { organizationId: string; periodStart: Date };
|
|
253
|
-
};
|
|
254
|
-
create: UsagePeriodUpsertData;
|
|
255
|
-
update: Record<string, unknown>;
|
|
256
|
-
}): Promise<unknown>;
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
export interface UsageMeteringPrismaDelegates extends UsageWriteClient {
|
|
261
|
-
llmModel: {
|
|
262
|
-
findFirst(args: {
|
|
263
|
-
where: { provider: string; model: string };
|
|
264
|
-
}): Promise<LlmModelRow | null>;
|
|
265
|
-
};
|
|
266
|
-
llmModelPricing: {
|
|
267
|
-
findFirst(args: unknown): Promise<LlmModelPricingRow | null>;
|
|
268
|
-
};
|
|
269
|
-
aiMarkupConfig: {
|
|
270
|
-
findFirst(args: unknown): Promise<AiMarkupConfigRow | null>;
|
|
271
|
-
};
|
|
272
|
-
$transaction<T>(fn: (tx: UsageWriteClient) => Promise<T>): Promise<T>;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const db = prisma as unknown as UsageMeteringPrismaDelegates;
|
|
276
|
-
|
|
277
|
-
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
278
|
-
|
|
279
|
-
const STANDARD_CONTEXT_WINDOW = 200_000;
|
|
280
|
-
const EXTENDED_CONTEXT_MULTIPLIER = 2;
|
|
281
|
-
const SYSTEM_DEFAULT_MARKUP_PERCENT = 20;
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Absurdity ceilings for the two operator-editable money inputs (M2). A rate or
|
|
285
|
-
* markup beyond these is a mis-seeded row, not a pricing decision — treated as
|
|
286
|
-
* absent rather than allowed to reach the cost math (where a `1e308` markup
|
|
287
|
-
* would produce an `Infinity` bill).
|
|
288
|
-
*/
|
|
289
|
-
const MAX_PRICE_PER_MILLION = 1_000_000;
|
|
290
|
-
const MAX_MARKUP_PERCENT = 100_000;
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Marks an event metered with NO pricing row (M4). Prefix-encoded onto the
|
|
294
|
-
* existing `completionStatus` column (no schema change — #3709 owns the
|
|
295
|
-
* schema), so the event is (a) distinguishable from a genuine $0 call and
|
|
296
|
-
* therefore re-priceable, and (b) still carries the caller's original status.
|
|
297
|
-
* A dedicated column is the right long-term home — see the PR follow-up.
|
|
298
|
-
*/
|
|
299
|
-
const PRICING_MISSING_PREFIX = "pricing_missing:";
|
|
300
|
-
|
|
301
|
-
const TIER_COLUMN: Record<
|
|
302
|
-
ModelTier,
|
|
303
|
-
"baseTokensConsumed" | "premiumTokensConsumed" | "frontierTokensConsumed"
|
|
304
|
-
> = {
|
|
305
|
-
BASE: "baseTokensConsumed",
|
|
306
|
-
PREMIUM: "premiumTokensConsumed",
|
|
307
|
-
FRONTIER: "frontierTokensConsumed",
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Guard for every DB-sourced money number (M2) — finite, non-negative, sane.
|
|
312
|
-
*
|
|
313
|
-
* The nullish/empty check is LOAD-BEARING and must come first: `Number(null)`
|
|
314
|
-
* and `Number("")` are both `0`, so a coercion-only guard would wave a NULL
|
|
315
|
-
* markup column through as a legitimate 0% — the silent revenue loss M2
|
|
316
|
-
* reports. A genuine numeric `0` still passes (a real 0% markup is valid).
|
|
317
|
-
*/
|
|
318
|
-
function money(value: unknown, max: number): number | null {
|
|
319
|
-
if (value === null || value === undefined || value === "") {
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
const n = Number(value);
|
|
323
|
-
return Number.isFinite(n) && n >= 0 && n <= max ? n : null;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// ─── Pure cost math ───────────────────────────────────────────────────────────
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Computes the raw provider cost for one AI call. Standard path: tokens/1e6 ×
|
|
330
|
-
* rate per bucket (cached input defaults to half the input rate when the
|
|
331
|
-
* model has no explicit cached rate). Extended-context path — ONLY when
|
|
332
|
-
* `chargeExtendedAt2x && contextWindowSize > 200_000` — splits each token
|
|
333
|
-
* bucket by the ratio of the 200K standard window to the actual context,
|
|
334
|
-
* charging the extended portion at 2x. `contextWindowSize` omitted/0 skips
|
|
335
|
-
* extended pricing ("don't guess").
|
|
336
|
-
*/
|
|
337
|
-
export function calculateProviderCost(
|
|
338
|
-
inputTokensNoCacheWrite: number,
|
|
339
|
-
cacheReadTokens: number,
|
|
340
|
-
outputTokens: number,
|
|
341
|
-
pricing: ModelPricing,
|
|
342
|
-
contextWindowSize = 0,
|
|
343
|
-
chargeExtendedAt2x = false,
|
|
344
|
-
): number {
|
|
345
|
-
const inputRate = pricing.inputPricePerMillion;
|
|
346
|
-
const cachedInputRate =
|
|
347
|
-
pricing.cachedInputPricePerMillion ?? pricing.inputPricePerMillion * 0.5;
|
|
348
|
-
const outputRate = pricing.outputPricePerMillion;
|
|
349
|
-
|
|
350
|
-
if (!chargeExtendedAt2x || contextWindowSize <= STANDARD_CONTEXT_WINDOW) {
|
|
351
|
-
return (
|
|
352
|
-
(inputTokensNoCacheWrite / 1_000_000) * inputRate +
|
|
353
|
-
(cacheReadTokens / 1_000_000) * cachedInputRate +
|
|
354
|
-
(outputTokens / 1_000_000) * outputRate
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const standardRatio = Math.min(1, STANDARD_CONTEXT_WINDOW / contextWindowSize);
|
|
359
|
-
const extendedRatio = 1 - standardRatio;
|
|
360
|
-
|
|
361
|
-
const splitCost = (tokens: number, rate: number): number =>
|
|
362
|
-
((tokens * standardRatio) / 1_000_000) * rate +
|
|
363
|
-
((tokens * extendedRatio) / 1_000_000) * rate * EXTENDED_CONTEXT_MULTIPLIER;
|
|
364
|
-
|
|
365
|
-
return (
|
|
366
|
-
splitCost(inputTokensNoCacheWrite, inputRate) +
|
|
367
|
-
splitCost(cacheReadTokens, cachedInputRate) +
|
|
368
|
-
splitCost(outputTokens, outputRate)
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ─── Resolution helpers ───────────────────────────────────────────────────────
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Most-recent active pricing row for (provider, model). Missing OR unusable
|
|
376
|
-
* (a non-finite / negative / absurd rate — M2) → null, which the caller meters
|
|
377
|
-
* at $0 and marks `pricing_missing` (M4). Never throws.
|
|
378
|
-
*/
|
|
379
|
-
async function resolvePricing(
|
|
380
|
-
provider: string,
|
|
381
|
-
model: string,
|
|
382
|
-
): Promise<ModelPricing | null> {
|
|
383
|
-
const now = new Date();
|
|
384
|
-
const row = await db.llmModelPricing.findFirst({
|
|
385
|
-
where: {
|
|
386
|
-
provider,
|
|
387
|
-
model,
|
|
388
|
-
effectiveFrom: { lte: now },
|
|
389
|
-
OR: [{ effectiveTo: null }, { effectiveTo: { gte: now } }],
|
|
390
|
-
},
|
|
391
|
-
orderBy: { effectiveFrom: "desc" },
|
|
392
|
-
select: {
|
|
393
|
-
inputPricePerMillion: true,
|
|
394
|
-
outputPricePerMillion: true,
|
|
395
|
-
cachedInputPricePerMillion: true,
|
|
396
|
-
},
|
|
397
|
-
});
|
|
398
|
-
if (!row) {
|
|
399
|
-
return null;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const inputPricePerMillion = money(row.inputPricePerMillion, MAX_PRICE_PER_MILLION);
|
|
403
|
-
const outputPricePerMillion = money(row.outputPricePerMillion, MAX_PRICE_PER_MILLION);
|
|
404
|
-
if (inputPricePerMillion === null || outputPricePerMillion === null) {
|
|
405
|
-
// A corrupt rate must never reach the cost math (it would write NaN/Infinity
|
|
406
|
-
// into the org's aggregate — unrecoverable). Treat as "no pricing".
|
|
407
|
-
console.warn("usage-metering: unusable pricing row", { provider, model });
|
|
408
|
-
return null;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return {
|
|
412
|
-
inputPricePerMillion,
|
|
413
|
-
outputPricePerMillion,
|
|
414
|
-
cachedInputPricePerMillion:
|
|
415
|
-
row.cachedInputPricePerMillion != null
|
|
416
|
-
? money(row.cachedInputPricePerMillion, MAX_PRICE_PER_MILLION)
|
|
417
|
-
: null,
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
interface ResolvedModelMeta {
|
|
422
|
-
tier: ModelTier;
|
|
423
|
-
wasAutoModel: boolean;
|
|
424
|
-
chargeExtendedContextAt2x: boolean;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Model catalog lookup. Unknown model → safe defaults (BASE, not auto, no
|
|
429
|
-
* extended surcharge). An out-of-union `tier` on the row (the column is
|
|
430
|
-
* operator-editable; the TS type is an assumption, not a guarantee) normalizes
|
|
431
|
-
* to BASE rather than producing an `undefined` tier column key (M3).
|
|
432
|
-
*/
|
|
433
|
-
async function resolveModelMetadata(
|
|
434
|
-
provider: string,
|
|
435
|
-
model: string,
|
|
436
|
-
): Promise<ResolvedModelMeta> {
|
|
437
|
-
const row = await db.llmModel.findFirst({ where: { provider, model } });
|
|
438
|
-
if (!row) {
|
|
439
|
-
return { tier: "BASE", wasAutoModel: false, chargeExtendedContextAt2x: false };
|
|
440
|
-
}
|
|
441
|
-
const tier: ModelTier = row.tier in TIER_COLUMN ? row.tier : "BASE";
|
|
442
|
-
if (tier !== row.tier) {
|
|
443
|
-
console.warn("usage-metering: unknown model tier, defaulting to BASE", {
|
|
444
|
-
provider,
|
|
445
|
-
model,
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
return {
|
|
449
|
-
tier,
|
|
450
|
-
wasAutoModel: row.isAutoModel,
|
|
451
|
-
chargeExtendedContextAt2x: row.chargeExtendedContextAt2x,
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Hierarchical markup resolution: model-specific → provider-wide →
|
|
457
|
-
* organization default → 20% system default (first VALID match wins).
|
|
458
|
-
*
|
|
459
|
-
* A row whose `markupPercent` is unusable — null (`Number(null) === 0`, a
|
|
460
|
-
* silent 0% = lost revenue), negative (billing BELOW provider cost), NaN, or
|
|
461
|
-
* absurd — is skipped, not obeyed (M2). The chain continues, terminating at
|
|
462
|
-
* the 20% system default.
|
|
463
|
-
*/
|
|
464
|
-
async function resolveMarkup(
|
|
465
|
-
organizationId: string,
|
|
466
|
-
provider: string,
|
|
467
|
-
model: string,
|
|
468
|
-
): Promise<number> {
|
|
469
|
-
const candidates = [
|
|
470
|
-
{ organizationId, provider, model },
|
|
471
|
-
{ organizationId, provider, model: null },
|
|
472
|
-
{ organizationId, provider: null, model: null },
|
|
473
|
-
];
|
|
474
|
-
|
|
475
|
-
for (const where of candidates) {
|
|
476
|
-
const row = await db.aiMarkupConfig.findFirst({ where });
|
|
477
|
-
if (!row) {
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
const markupPercent = money(row.markupPercent, MAX_MARKUP_PERCENT);
|
|
481
|
-
if (markupPercent !== null) {
|
|
482
|
-
return markupPercent;
|
|
483
|
-
}
|
|
484
|
-
console.warn("usage-metering: unusable markup row, falling through", {
|
|
485
|
-
organizationId,
|
|
486
|
-
provider: where.provider,
|
|
487
|
-
model: where.model,
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
return SYSTEM_DEFAULT_MARKUP_PERCENT;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function startOfMonthUtc(date: Date): Date {
|
|
495
|
-
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function startOfNextMonthUtc(periodStart: Date): Date {
|
|
499
|
-
return new Date(
|
|
500
|
-
Date.UTC(periodStart.getUTCFullYear(), periodStart.getUTCMonth() + 1, 1),
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Upserts the month's tier aggregate — INSIDE the caller's transaction (H3), so
|
|
506
|
-
* a failure here rolls the `UsageEvent` back with it.
|
|
507
|
-
*
|
|
508
|
-
* NOTE (H1/H2, deliberately unchanged): `Math.ceil(billedCostUsd * 100)` both
|
|
509
|
-
* inherits IEEE-754 error (0.07 * 100 → 7.000000000000001 → 8¢) and rounds every
|
|
510
|
-
* sub-cent call UP to a whole cent, which compounds into systematic overbilling.
|
|
511
|
-
* The fix is a spec-level billing-fairness decision (accumulate sub-cent
|
|
512
|
-
* precision, round once at invoice time) that would subsume the float fix —
|
|
513
|
-
* escalated to Sam, deliberately NOT patched here (PR #3727).
|
|
514
|
-
*/
|
|
515
|
-
async function upsertUsagePeriod(
|
|
516
|
-
tx: UsageWriteClient,
|
|
517
|
-
params: {
|
|
518
|
-
organizationId: string;
|
|
519
|
-
tier: ModelTier;
|
|
520
|
-
totalTokens: number;
|
|
521
|
-
billedCostUsd: number;
|
|
522
|
-
},
|
|
523
|
-
): Promise<void> {
|
|
524
|
-
const { organizationId, tier, totalTokens, billedCostUsd } = params;
|
|
525
|
-
const periodStart = startOfMonthUtc(new Date());
|
|
526
|
-
const periodEnd = startOfNextMonthUtc(periodStart);
|
|
527
|
-
const usageSpendCents = Math.ceil(billedCostUsd * 100);
|
|
528
|
-
const tierColumn = TIER_COLUMN[tier] ?? TIER_COLUMN.BASE;
|
|
529
|
-
|
|
530
|
-
await tx.usagePeriod.upsert({
|
|
531
|
-
where: { organizationId_periodStart: { organizationId, periodStart } },
|
|
532
|
-
create: {
|
|
533
|
-
organizationId,
|
|
534
|
-
periodStart,
|
|
535
|
-
periodEnd,
|
|
536
|
-
tokensConsumed: totalTokens,
|
|
537
|
-
usageSpendCents,
|
|
538
|
-
[tierColumn]: totalTokens,
|
|
539
|
-
},
|
|
540
|
-
update: {
|
|
541
|
-
tokensConsumed: { increment: totalTokens },
|
|
542
|
-
usageSpendCents: { increment: usageSpendCents },
|
|
543
|
-
[tierColumn]: { increment: totalTokens },
|
|
544
|
-
},
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Records one billable AI call: validates the input, resolves model metadata +
|
|
552
|
-
* pricing, computes the provider cost (incl. the extended-context surcharge),
|
|
553
|
-
* resolves the hierarchical markup, then writes the `UsageEvent` (attribution
|
|
554
|
-
* portions = 0 — populated later by the credit waterfall) and upserts the
|
|
555
|
-
* `UsagePeriod` tier aggregate ATOMICALLY.
|
|
556
|
-
*
|
|
557
|
-
* Never throws. Invalid caller input → `invalid_input`; a DB failure →
|
|
558
|
-
* `write_failed`. Both carry `persisted: false` — the transaction guarantees
|
|
559
|
-
* nothing landed, so the caller may always retry (H3). Missing pricing does not
|
|
560
|
-
* fail the call: metering never blocks on a missing rate; the event is metered
|
|
561
|
-
* at $0 and marked `pricing_missing` so it can be re-priced (M4).
|
|
562
|
-
*/
|
|
563
|
-
export async function recordUsageEvent(
|
|
564
|
-
input: RecordUsageEventInput,
|
|
565
|
-
): Promise<RecordUsageResult> {
|
|
566
|
-
const parsed = recordUsageEventSchema.safeParse(input);
|
|
567
|
-
if (!parsed.success) {
|
|
568
|
-
const issue = parsed.error.issues[0];
|
|
569
|
-
const field = issue?.path.join(".") ?? "input";
|
|
570
|
-
return {
|
|
571
|
-
ok: false,
|
|
572
|
-
error: {
|
|
573
|
-
kind: "invalid_input",
|
|
574
|
-
message: `${field}: ${issue?.message ?? "invalid input"}`,
|
|
575
|
-
persisted: false,
|
|
576
|
-
},
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const {
|
|
581
|
-
tenant,
|
|
582
|
-
provider,
|
|
583
|
-
model,
|
|
584
|
-
inputTokensCacheWrite = 0,
|
|
585
|
-
inputTokensNoCacheWrite,
|
|
586
|
-
cacheReadTokens = 0,
|
|
587
|
-
outputTokens,
|
|
588
|
-
thinkingTokens = 0,
|
|
589
|
-
contextWindowSize = 0,
|
|
590
|
-
completionStatus = "success",
|
|
591
|
-
} = parsed.data;
|
|
592
|
-
|
|
593
|
-
// Branded by `zTenantContext` (H5) — an `Id<"organization">`, not a bare string.
|
|
594
|
-
const organizationId = tenant.organizationId;
|
|
595
|
-
|
|
596
|
-
// Derived server-side, never caller-supplied (M1): totalTokens alone drives
|
|
597
|
-
// `tokensConsumed` + the tier column — i.e. the QUOTA surface. A caller
|
|
598
|
-
// passing `totalTokens: 0` alongside real buckets would otherwise be billed
|
|
599
|
-
// for the call while burning no quota.
|
|
600
|
-
const totalTokens =
|
|
601
|
-
inputTokensCacheWrite +
|
|
602
|
-
inputTokensNoCacheWrite +
|
|
603
|
-
cacheReadTokens +
|
|
604
|
-
outputTokens +
|
|
605
|
-
thinkingTokens;
|
|
606
|
-
|
|
607
|
-
const modelMeta = await resolveModelMetadata(provider, model);
|
|
608
|
-
|
|
609
|
-
const pricing = await resolvePricing(provider, model);
|
|
610
|
-
let providerCostUsd = 0;
|
|
611
|
-
if (pricing) {
|
|
612
|
-
providerCostUsd = calculateProviderCost(
|
|
613
|
-
inputTokensNoCacheWrite,
|
|
614
|
-
cacheReadTokens,
|
|
615
|
-
outputTokens,
|
|
616
|
-
pricing,
|
|
617
|
-
contextWindowSize,
|
|
618
|
-
modelMeta.chargeExtendedContextAt2x,
|
|
619
|
-
);
|
|
620
|
-
} else {
|
|
621
|
-
// Missing pricing never blocks metering — but it is NOT silent (M4): the
|
|
622
|
-
// event is marked so it is distinguishable from a real $0 call and can be
|
|
623
|
-
// re-priced once the pricing row is seeded. (ids/model only — no token content.)
|
|
624
|
-
console.warn("usage-metering: no active pricing found", {
|
|
625
|
-
organizationId,
|
|
626
|
-
provider,
|
|
627
|
-
model,
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
const markupPercent = await resolveMarkup(organizationId, provider, model);
|
|
632
|
-
const billedCostUsd = providerCostUsd * (1 + markupPercent / 100);
|
|
633
|
-
|
|
634
|
-
try {
|
|
635
|
-
// ATOMIC (H3): the event insert and the aggregate upsert are ONE unit. A
|
|
636
|
-
// failed upsert (incl. the P2002 upsert race on a fresh period) rolls the
|
|
637
|
-
// event back, so `ok: false` always means "nothing persisted" and a retry
|
|
638
|
-
// can never double-bill.
|
|
639
|
-
const eventId = await db.$transaction(async (tx) => {
|
|
640
|
-
const event = await tx.usageEvent.create({
|
|
641
|
-
data: {
|
|
642
|
-
organizationId,
|
|
643
|
-
workspaceId: tenant.workspaceId,
|
|
644
|
-
userId: tenant.userId,
|
|
645
|
-
provider,
|
|
646
|
-
model,
|
|
647
|
-
inputTokensCacheWrite,
|
|
648
|
-
inputTokensNoCacheWrite,
|
|
649
|
-
cacheReadTokens,
|
|
650
|
-
outputTokens,
|
|
651
|
-
thinkingTokens,
|
|
652
|
-
totalTokens,
|
|
653
|
-
providerCostUsd,
|
|
654
|
-
markupPercent,
|
|
655
|
-
billedCostUsd,
|
|
656
|
-
modelTier: modelMeta.tier,
|
|
657
|
-
wasAutoModel: modelMeta.wasAutoModel,
|
|
658
|
-
includedPortionCents: 0,
|
|
659
|
-
creditBalancePortionCents: 0,
|
|
660
|
-
onDemandPortionCents: 0,
|
|
661
|
-
completionStatus: pricing
|
|
662
|
-
? completionStatus
|
|
663
|
-
: `${PRICING_MISSING_PREFIX}${completionStatus}`,
|
|
664
|
-
},
|
|
665
|
-
select: { id: true },
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
await upsertUsagePeriod(tx, {
|
|
669
|
-
organizationId,
|
|
670
|
-
tier: modelMeta.tier,
|
|
671
|
-
totalTokens,
|
|
672
|
-
billedCostUsd,
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
return event.id;
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
return { ok: true, eventId, billedCostUsd };
|
|
679
|
-
} catch (error) {
|
|
680
|
-
// Log the detail server-side; NEVER return it (M5). Prisma validation
|
|
681
|
-
// errors embed the full `data` argument — org/workspace/user ids and token
|
|
682
|
-
// counts — which a route could forward to an untrusted caller.
|
|
683
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
684
|
-
console.error("usage-metering: write failed", {
|
|
685
|
-
organizationId,
|
|
686
|
-
provider,
|
|
687
|
-
model,
|
|
688
|
-
detail,
|
|
689
|
-
});
|
|
690
|
-
return {
|
|
691
|
-
ok: false,
|
|
692
|
-
error: {
|
|
693
|
-
kind: "write_failed",
|
|
694
|
-
message: "usage event write failed",
|
|
695
|
-
persisted: false,
|
|
696
|
-
},
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
}
|