Skip to content

Commit e1a8a24

Browse files
fix(billing): unify chat usage source
1 parent edff347 commit e1a8a24

17 files changed

Lines changed: 333 additions & 97 deletions

File tree

apps/docs/openapi-core.json

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,7 @@
10181018
"get": {
10191019
"operationId": "getUsageSummary",
10201020
"summary": "Get Usage Summary",
1021-
"description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `copilot`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.",
1021+
"description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `sim-chat`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. Sim Chat combines the internal Copilot and workspace-chat ledgers. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.",
10221022
"tags": ["Usage"],
10231023
"security": [
10241024
{
@@ -1098,7 +1098,7 @@
10981098
"totalCredits": 512,
10991099
"bySourceCredits": {
11001100
"workflow": 380,
1101-
"copilot": 120,
1101+
"sim-chat": 120,
11021102
"knowledge-base": 12
11031103
},
11041104
"limitCredits": 20000,
@@ -1140,9 +1140,20 @@
11401140
"in": "query",
11411141
"required": false,
11421142
"schema": {
1143-
"type": "string"
1143+
"type": "string",
1144+
"enum": [
1145+
"workflow",
1146+
"wand",
1147+
"sim-chat",
1148+
"mcp_copilot",
1149+
"mothership_block",
1150+
"knowledge-base",
1151+
"voice-input",
1152+
"enrichment",
1153+
"voice-output"
1154+
]
11441155
},
1145-
"description": "Restrict to one usage source (e.g. `workflow`, `copilot`)."
1156+
"description": "Restrict to one usage source (e.g. `workflow`, `sim-chat`). `sim-chat` includes both the internal Copilot and workspace-chat ledgers."
11461157
},
11471158
{
11481159
"name": "workspaceId",
@@ -1225,7 +1236,18 @@
12251236
"format": "date-time"
12261237
},
12271238
"source": {
1228-
"type": "string"
1239+
"type": "string",
1240+
"enum": [
1241+
"workflow",
1242+
"wand",
1243+
"sim-chat",
1244+
"mcp_copilot",
1245+
"mothership_block",
1246+
"knowledge-base",
1247+
"voice-input",
1248+
"enrichment",
1249+
"voice-output"
1250+
]
12291251
},
12301252
"workflowName": {
12311253
"type": ["string", "null"],
@@ -1249,7 +1271,7 @@
12491271
{
12501272
"id": "log_1",
12511273
"createdAt": "2026-07-29T18:04:11.000Z",
1252-
"source": "copilot",
1274+
"source": "sim-chat",
12531275
"workflowName": null,
12541276
"creditCost": 12
12551277
}

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('GET /api/users/me/usage-logs/export', () => {
5757
expect(response.headers.get('Content-Disposition')).toContain('attachment; filename=')
5858
expect(response.headers.get('X-Export-Truncated')).toBe('0')
5959
expect(header).toBe('Date,Type,Credits')
60-
expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100')
60+
expect(row).toBe('2026-07-01T00:00:00.000Z,Sim Chat,100')
6161
})
6262

6363
it('sets X-Export-Truncated when the safety cap is hit with more data remaining', async () => {
@@ -92,6 +92,28 @@ describe('GET /api/users/me/usage-logs/export', () => {
9292
)
9393
})
9494

95+
it('filters sim-chat across both internal ledgers', async () => {
96+
mockGetUserUsageLogs.mockResolvedValueOnce({
97+
logs: [],
98+
summary: { totalCost: 0, bySource: {} },
99+
pagination: { hasMore: false },
100+
})
101+
102+
const response = await GET(
103+
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?source=sim-chat')
104+
)
105+
106+
expect(response.status).toBe(200)
107+
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
108+
'user-1',
109+
expect.objectContaining({ source: ['copilot', 'workspace-chat'] })
110+
)
111+
expect(mockGetUsageCreditsByLogId).toHaveBeenCalledWith(
112+
'user-1',
113+
expect.objectContaining({ source: ['copilot', 'workspace-chat'] })
114+
)
115+
})
116+
95117
it('names the specific workflow for workflow-sourced rows', async () => {
96118
mockGetUserUsageLogs.mockResolvedValueOnce({
97119
logs: [

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { exportUsageLogsContract } from '@/lib/api/contracts/user'
44
import { parseRequest } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log'
67
import {
7-
getUsageCreditsByLogId,
8-
getUserUsageLogs,
9-
type UsageLogSource,
10-
} from '@/lib/billing/core/usage-log'
8+
BILLING_USAGE_LOG_SOURCE_LABELS,
9+
toBillingUsageLogSource,
10+
toInternalUsageLogSources,
11+
} from '@/lib/billing/usage-sources'
1112
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1213
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
1314
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
14-
import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels'
1515

1616
const logger = createLogger('UsageLogsExportAPI')
1717

@@ -44,7 +44,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4444

4545
const dateRange = resolveDateRange(period, startDate, endDate)
4646
const filter = {
47-
source: source as UsageLogSource | undefined,
47+
source: source ? toInternalUsageLogSources(source) : undefined,
4848
workspaceId,
4949
startDate: dateRange.startDate,
5050
endDate: dateRange.endDate,
@@ -84,7 +84,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
8484
const type =
8585
log.source === 'workflow' && log.workflowName
8686
? `Workflow: ${log.workflowName}`
87-
: USAGE_LOG_SOURCE_LABELS[log.source]
87+
: BILLING_USAGE_LOG_SOURCE_LABELS[toBillingUsageLogSource(log.source)]
8888
return toCsvRow([
8989
formatCsvValue(log.createdAt),
9090
formatCsvValue(type),

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,52 @@ describe('GET /api/users/me/usage-logs', () => {
9393
expect(body.logs[0].workflowName).toBe('ITSM_Prod_main')
9494
})
9595

96+
it('presents copilot and workspace-chat usage as one sim-chat source', async () => {
97+
mockGetUserUsageLogs.mockResolvedValue({
98+
logs: [
99+
{
100+
id: 'log-copilot',
101+
createdAt: '2026-07-01T00:00:00.000Z',
102+
category: 'model',
103+
source: 'copilot',
104+
description: 'claude-opus',
105+
cost: 0.4,
106+
},
107+
{
108+
id: 'log-workspace-chat',
109+
createdAt: '2026-07-01T00:00:00.000Z',
110+
category: 'model',
111+
source: 'workspace-chat',
112+
description: 'claude-opus',
113+
cost: 0.2,
114+
},
115+
],
116+
summary: { totalCost: 0.6, bySource: { copilot: 0.4, 'workspace-chat': 0.2 } },
117+
pagination: { hasMore: false },
118+
})
119+
mockGetUsageCreditsByLogId.mockResolvedValue({
120+
'log-copilot': 80,
121+
'log-workspace-chat': 40,
122+
})
123+
124+
const body = await (await GET(createMockRequest('GET'))).json()
125+
126+
expect(body.logs.map((log: { source: string }) => log.source)).toEqual(['sim-chat', 'sim-chat'])
127+
expect(body.summary.bySourceCredits).toEqual({ 'sim-chat': 120 })
128+
})
129+
130+
it('filters sim-chat across both internal ledgers', async () => {
131+
const response = await GET(
132+
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?source=sim-chat')
133+
)
134+
135+
expect(response.status).toBe(200)
136+
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
137+
'user-1',
138+
expect.objectContaining({ source: ['copilot', 'workspace-chat'] })
139+
)
140+
})
141+
96142
it('rejects "custom" period without a startDate', async () => {
97143
const response = await GET(
98144
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=custom')

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

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { getUsageLogsContract } from '@/lib/api/contracts/user'
44
import { parseRequest } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6-
import {
7-
getUsageCreditsByLogId,
8-
getUserUsageLogs,
9-
type UsageLogSource,
10-
} from '@/lib/billing/core/usage-log'
6+
import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log'
117
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
8+
import {
9+
aggregateBillingUsageBySource,
10+
toBillingUsageLogSource,
11+
toInternalUsageLogSources,
12+
} from '@/lib/billing/usage-sources'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1314
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
1415

@@ -33,7 +34,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3334
const dateRange = resolveDateRange(period, startDate, endDate)
3435

3536
const filter = {
36-
source: source as UsageLogSource | undefined,
37+
source: source ? toInternalUsageLogSources(source) : undefined,
3738
workspaceId,
3839
startDate: dateRange.startDate,
3940
endDate: dateRange.endDate,
@@ -49,17 +50,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4950
const logs = result.logs.map((log) => ({
5051
id: log.id,
5152
createdAt: log.createdAt,
52-
source: log.source,
53+
source: toBillingUsageLogSource(log.source),
5354
workflowName: log.workflowName ?? null,
5455
creditCost: creditsByLogId[log.id] ?? 0,
5556
hasCost: log.cost > 0,
5657
}))
5758

5859
const bySourceCredits = Object.fromEntries(
59-
Object.entries(result.summary.bySource).map(([sourceKey, cost]) => [
60-
sourceKey,
61-
dollarsToCredits(cost),
62-
])
60+
Object.entries(aggregateBillingUsageBySource(result.summary.bySource)).map(
61+
([sourceKey, cost]) => [sourceKey, dollarsToCredits(cost)]
62+
)
6363
)
6464

6565
logger.debug('Retrieved usage logs', {

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

Lines changed: 0 additions & 21 deletions
This file was deleted.

apps/sim/app/api/v2/billing/usage/logs/route.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ describe('GET /api/v2/billing/usage/logs', () => {
7171
{
7272
id: 'log-1',
7373
createdAt: '2026-07-01T00:00:00.000Z',
74-
source: 'copilot',
74+
source: 'sim-chat',
7575
workflowName: null,
7676
creditCost: 12,
7777
},
@@ -90,6 +90,27 @@ describe('GET /api/v2/billing/usage/logs', () => {
9090
expect(body.nextCursor).toBe('log-42')
9191
})
9292

93+
it('filters sim-chat across both internal ledgers', async () => {
94+
const res = await callLogs('?source=sim-chat')
95+
96+
expect(res.status).toBe(200)
97+
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
98+
'user-1',
99+
expect.objectContaining({ source: ['copilot', 'workspace-chat'] })
100+
)
101+
expect(mockGetUsageCreditsByLogId).toHaveBeenCalledWith(
102+
'user-1',
103+
expect.objectContaining({ source: ['copilot', 'workspace-chat'] })
104+
)
105+
})
106+
107+
it('rejects internal chat source names', async () => {
108+
const res = await callLogs('?source=copilot')
109+
110+
expect(res.status).toBe(400)
111+
expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
112+
})
113+
93114
it('pins a workspace API key to its own workspace', async () => {
94115
mockCheckRateLimit.mockResolvedValue({
95116
...RATE_LIMIT_OK,

apps/sim/app/api/v2/billing/usage/logs/route.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import type { NextRequest } from 'next/server'
44
import { v2ListUsageLogsContract } from '@/lib/api/contracts/v2/billing'
55
import { parseRequest } from '@/lib/api/server'
6-
import {
7-
getUsageCreditsByLogId,
8-
getUserUsageLogs,
9-
type UsageLogSource,
10-
} from '@/lib/billing/core/usage-log'
6+
import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log'
7+
import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources'
118
import { generateRequestId } from '@/lib/core/utils/request'
129
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1310
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
@@ -59,7 +56,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5956

6057
const dateRange = resolveDateRange(period, startDate, endDate)
6158
const filter = {
62-
source: source as UsageLogSource | undefined,
59+
source: source ? toInternalUsageLogSources(source) : undefined,
6360
workspaceId: workspaceFilter.workspaceId,
6461
startDate: dateRange.startDate,
6562
endDate: dateRange.endDate,
@@ -73,7 +70,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7370
const items = result.logs.map((log) => ({
7471
id: log.id,
7572
createdAt: log.createdAt,
76-
source: log.source,
73+
source: toBillingUsageLogSource(log.source),
7774
workflowName: log.workflowName ?? null,
7875
creditCost: creditsByLogId[log.id] ?? 0,
7976
}))

apps/sim/app/api/v2/billing/usage/route.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ describe('GET /api/v2/billing/usage', () => {
7373
})
7474
mockGetUserUsageLogs.mockResolvedValue({
7575
logs: [],
76-
summary: { totalCost: 2.5, bySource: { workflow: 1.9, copilot: 0.6 } },
76+
summary: {
77+
totalCost: 2.5,
78+
bySource: { workflow: 1.9, copilot: 0.4, 'workspace-chat': 0.2 },
79+
},
7780
pagination: { hasMore: false },
7881
})
7982
})
@@ -85,7 +88,7 @@ describe('GET /api/v2/billing/usage', () => {
8588
expect(body.data).toEqual({
8689
period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' },
8790
totalCredits: 500,
88-
bySourceCredits: { workflow: 380, copilot: 120 },
91+
bySourceCredits: { workflow: 380, 'sim-chat': 120 },
8992
limitCredits: 20000,
9093
plan: 'pro',
9194
})

apps/sim/app/api/v2/billing/usage/route.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { checkServerSideUsageLimits } from '@/lib/billing'
77
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
88
import { deriveBillingContext, getUserUsageLogs } from '@/lib/billing/core/usage-log'
99
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
10+
import { aggregateBillingUsageBySource } from '@/lib/billing/usage-sources'
1011
import { generateRequestId } from '@/lib/core/utils/request'
1112
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1213
import { checkRateLimit } from '@/app/api/v1/middleware'
@@ -22,7 +23,7 @@ export const revalidate = 0
2223
/**
2324
* GET /api/v2/billing/usage — Current-billing-period usage summary with the
2425
* per-source credit breakdown, for external monitoring (e.g. alerting on
25-
* Copilot consumption before an overage). Credits only — dollar costs and
26+
* Sim Chat consumption before an overage). Credits only — dollar costs and
2627
* rate-limit internals are not part of this surface.
2728
*/
2829
export const GET = withRouteHandler(async (request: NextRequest) => {
@@ -65,10 +66,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6566
])
6667

6768
const bySourceCredits = Object.fromEntries(
68-
Object.entries(ledger.summary.bySource).map(([source, cost]) => [
69-
source,
70-
dollarsToCredits(cost),
71-
])
69+
Object.entries(aggregateBillingUsageBySource(ledger.summary.bySource)).map(
70+
([source, cost]) => [source, dollarsToCredits(cost)]
71+
)
7272
)
7373

7474
const data: V2UsageSummaryData = {

0 commit comments

Comments
 (0)