Skip to content

Commit 3a3ceb5

Browse files
feat(billing): dedicated v2 usage endpoints; keep internal usage routes session-only
Replaces the earlier X-API-Key enablement on /api/users/me/usage-logs with a dedicated public surface, so the internal Billing-settings endpoints can evolve with the UI while external monitors get a stable versioned contract: - GET /api/v2/billing/usage — current-billing-period summary with bySourceCredits (the source breakdown external monitors need to watch e.g. Copilot consumption without estimating by subtraction), plus limitCredits and plan - GET /api/v2/billing/usage/logs — cursor-paged credit ledger in the v2 envelope - workspace-scoped keys are pinned to their own workspace's slice; personal keys read the account ledger The public wire is credits-only: usage-logs rows now carry a hasCost boolean instead of dollarCost (the Billing UI only needed the >0 signal), and the rateLimit block is removed from the usage-limits response and docs (deploy-modal tab relabeled accordingly). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz
1 parent 65dc7e8 commit 3a3ceb5

18 files changed

Lines changed: 738 additions & 448 deletions

File tree

apps/docs/openapi-core.json

Lines changed: 131 additions & 285 deletions
Large diffs are not rendered by default.

apps/sim/app/api/users/me/usage-limits/route.ts

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits'
5-
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
5+
import { checkHybridAuth } from '@/lib/auth/hybrid'
66
import { checkServerSideUsageLimits } from '@/lib/billing'
77
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
88
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
9-
import { RateLimiter } from '@/lib/core/rate-limiter'
109
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1110
import { createErrorResponse } from '@/app/api/workflows/utils'
1211

@@ -23,22 +22,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2322
const authenticatedUserId = auth.userId
2423

2524
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
26-
const rateLimiter = new RateLimiter()
27-
const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual'
28-
const [syncStatus, asyncStatus] = await Promise.all([
29-
rateLimiter.getRateLimitStatusWithSubscription(
30-
authenticatedUserId,
31-
userSubscription,
32-
triggerType,
33-
false
34-
),
35-
rateLimiter.getRateLimitStatusWithSubscription(
36-
authenticatedUserId,
37-
userSubscription,
38-
triggerType,
39-
true
40-
),
41-
])
4225

4326
const [usageCheck, storageUsage, storageLimit] = await Promise.all([
4427
checkServerSideUsageLimits(authenticatedUserId),
@@ -52,23 +35,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5235

5336
return NextResponse.json({
5437
success: true,
55-
rateLimit: {
56-
sync: {
57-
isLimited: syncStatus.remaining === 0,
58-
requestsPerMinute: syncStatus.requestsPerMinute,
59-
maxBurst: syncStatus.maxBurst,
60-
remaining: syncStatus.remaining,
61-
resetAt: syncStatus.resetAt,
62-
},
63-
async: {
64-
isLimited: asyncStatus.remaining === 0,
65-
requestsPerMinute: asyncStatus.requestsPerMinute,
66-
maxBurst: asyncStatus.maxBurst,
67-
remaining: asyncStatus.remaining,
68-
resetAt: asyncStatus.resetAt,
69-
},
70-
authType: triggerType,
71-
},
7238
usage: {
7339
currentPeriodCost,
7440
limit: usageCheck.limit,

apps/sim/app/api/users/me/usage-logs/export/route.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,15 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { exportUsageLogsContract } from '@/lib/api/contracts/user'
44
import { parseRequest } from '@/lib/api/server'
5-
import { checkHybridAuth } from '@/lib/auth/hybrid'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import {
77
getUsageCreditsByLogId,
88
getUserUsageLogs,
99
type UsageLogSource,
1010
} from '@/lib/billing/core/usage-log'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
13-
import {
14-
resolveDateRange,
15-
resolveUsageLogsWorkspaceFilter,
16-
} from '@/app/api/users/me/usage-logs/shared'
13+
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
1714
import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels'
1815

1916
const logger = createLogger('UsageLogsExportAPI')
@@ -36,7 +33,7 @@ const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits'])
3633
* (unlike, say, a workspace's full execution history).
3734
*/
3835
export const GET = withRouteHandler(async (request: NextRequest) => {
39-
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
36+
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
4037
if (!auth.success || !auth.userId) {
4138
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
4239
}
@@ -45,13 +42,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4542
if (!parsed.success) return parsed.response
4643
const { source, workspaceId, period, startDate, endDate } = parsed.data.query
4744

48-
const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId)
49-
if (!workspaceFilter.ok) return workspaceFilter.response
50-
5145
const dateRange = resolveDateRange(period, startDate, endDate)
5246
const filter = {
5347
source: source as UsageLogSource | undefined,
54-
workspaceId: workspaceFilter.workspaceId,
48+
workspaceId,
5549
startDate: dateRange.startDate,
5650
endDate: dateRange.endDate,
5751
}

apps/sim/app/api/users/me/usage-logs/route.test.ts

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { authMockFns, createMockRequest, hybridAuthMockFns } from '@sim/testing'
4+
import { authMockFns, createMockRequest } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import { apportionCredits } from '@/lib/billing/credits/conversion'
77

@@ -46,58 +46,6 @@ describe('GET /api/users/me/usage-logs', () => {
4646
expect(response.status).toBe(401)
4747
})
4848

49-
it('accepts a personal API key and reads that user’s ledger unscoped', async () => {
50-
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
51-
success: true,
52-
userId: 'key-owner',
53-
authType: 'api_key',
54-
apiKeyType: 'personal',
55-
})
56-
57-
const response = await GET(createMockRequest('GET'))
58-
59-
expect(response.status).toBe(200)
60-
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
61-
'key-owner',
62-
expect.objectContaining({ workspaceId: undefined })
63-
)
64-
})
65-
66-
it('pins a workspace API key to its own workspace’s slice of the ledger', async () => {
67-
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
68-
success: true,
69-
userId: 'key-owner',
70-
workspaceId: 'ws-1',
71-
authType: 'api_key',
72-
apiKeyType: 'workspace',
73-
})
74-
75-
const response = await GET(createMockRequest('GET'))
76-
77-
expect(response.status).toBe(200)
78-
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
79-
'key-owner',
80-
expect.objectContaining({ workspaceId: 'ws-1' })
81-
)
82-
})
83-
84-
it('rejects a workspace API key asking for a different workspace', async () => {
85-
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
86-
success: true,
87-
userId: 'key-owner',
88-
workspaceId: 'ws-1',
89-
authType: 'api_key',
90-
apiKeyType: 'workspace',
91-
})
92-
93-
const response = await GET(
94-
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-2')
95-
)
96-
97-
expect(response.status).toBe(403)
98-
expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
99-
})
100-
10149
it('converts dollar costs to credits in the logs and summary', async () => {
10250
const response = await GET(createMockRequest('GET'))
10351
const body = await response.json()
@@ -109,7 +57,7 @@ describe('GET /api/users/me/usage-logs', () => {
10957
source: 'workflow',
11058
workflowName: null,
11159
creditCost: 100,
112-
dollarCost: 0.5,
60+
hasCost: true,
11361
},
11462
])
11563
expect(body.summary).toEqual({

apps/sim/app/api/users/me/usage-logs/route.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,25 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { getUsageLogsContract } from '@/lib/api/contracts/user'
44
import { parseRequest } from '@/lib/api/server'
5-
import { checkHybridAuth } from '@/lib/auth/hybrid'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import {
77
getUsageCreditsByLogId,
88
getUserUsageLogs,
99
type UsageLogSource,
1010
} from '@/lib/billing/core/usage-log'
1111
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13-
import {
14-
resolveDateRange,
15-
resolveUsageLogsWorkspaceFilter,
16-
} from '@/app/api/users/me/usage-logs/shared'
13+
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
1714

1815
const logger = createLogger('UsageLogsAPI')
1916

2017
/**
2118
* Lists the authenticated user's credit-consuming usage events (model, tool,
2219
* and fixed charges), converted to credits for display in Billing settings.
23-
*
24-
* Accepts session auth AND `X-API-Key` (matching `/api/users/me/usage-limits`,
25-
* whose aggregate `currentPeriodCost` this endpoint's `summary` breaks down by
26-
* source) so external monitors can watch e.g. Copilot consumption. Workspace
27-
* keys are pinned to their own workspace's slice of the ledger.
20+
* Session-only — the API-key-facing equivalent is `GET /api/v2/billing/usage/logs`.
2821
*/
2922
export const GET = withRouteHandler(async (request: NextRequest) => {
30-
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
23+
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
3124
if (!auth.success || !auth.userId) {
3225
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3326
}
@@ -37,14 +30,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3730
const { source, workspaceId, period, startDate, endDate, limit, cursor, includeCredits } =
3831
parsed.data.query
3932

40-
const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId)
41-
if (!workspaceFilter.ok) return workspaceFilter.response
42-
4333
const dateRange = resolveDateRange(period, startDate, endDate)
4434

4535
const filter = {
4636
source: source as UsageLogSource | undefined,
47-
workspaceId: workspaceFilter.workspaceId,
37+
workspaceId,
4838
startDate: dateRange.startDate,
4939
endDate: dateRange.endDate,
5040
}
@@ -62,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6252
source: log.source,
6353
workflowName: log.workflowName ?? null,
6454
creditCost: creditsByLogId[log.id] ?? 0,
65-
dollarCost: log.cost,
55+
hasCost: log.cost > 0,
6656
}))
6757

6858
const bySourceCredits = Object.fromEntries(

apps/sim/app/api/users/me/usage-logs/shared.ts

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,7 @@
1-
import { NextResponse } from 'next/server'
21
import type { UsageLogPeriod } from '@/lib/api/contracts/user'
3-
import type { AuthResult } from '@/lib/auth/hybrid'
42

53
const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 }
64

7-
type WorkspaceFilterResult =
8-
| { ok: true; workspaceId: string | undefined }
9-
| { ok: false; response: NextResponse }
10-
11-
/**
12-
* Resolves the effective `workspaceId` ledger filter for the caller's
13-
* credential. Sessions, internal JWTs, and personal API keys read the
14-
* authenticated user's full ledger with whatever filter they asked for; a
15-
* workspace-scoped API key is pinned to its own workspace — the filter
16-
* defaults to the key's workspace and an explicit mismatch is rejected rather
17-
* than silently ignored.
18-
*/
19-
export function resolveUsageLogsWorkspaceFilter(
20-
auth: AuthResult,
21-
requestedWorkspaceId: string | undefined
22-
): WorkspaceFilterResult {
23-
if (auth.apiKeyType !== 'workspace') return { ok: true, workspaceId: requestedWorkspaceId }
24-
if (requestedWorkspaceId && requestedWorkspaceId !== auth.workspaceId) {
25-
return {
26-
ok: false,
27-
response: NextResponse.json(
28-
{ error: 'API key is not authorized for this workspace' },
29-
{ status: 403 }
30-
),
31-
}
32-
}
33-
return { ok: true, workspaceId: auth.workspaceId }
34-
}
35-
365
interface ResolvedDateRange {
376
startDate: Date | undefined
387
endDate: Date

apps/sim/app/api/v1/middleware.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export type ApiEndpoint =
4444
| 'knowledge-detail'
4545
| 'knowledge-search'
4646
| 'copilot-chat'
47+
| 'billing-usage'
4748

4849
export interface RateLimitResult {
4950
allowed: boolean

0 commit comments

Comments
 (0)