Skip to content

Commit 0e5aa4f

Browse files
j15zclaude
andcommitted
feat(copilot): answer account billing questions from a live snapshot
Adds the no-argument get_account_billing tool: the handler combines the org-aware billing lookups (usage data, credit balance, usage-limit info) into one snapshot — plan, current-period usage vs limit with remaining, and purchased credit balance — always scoped to the requesting user. Catalog and schema mirrors regenerated from the mothership definitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cc239a9 commit 0e5aa4f

6 files changed

Lines changed: 186 additions & 0 deletions

File tree

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface ToolCatalogEntry {
5656
| 'generate_audio'
5757
| 'generate_image'
5858
| 'generate_video'
59+
| 'get_account_billing'
5960
| 'get_block_outputs'
6061
| 'get_block_upstream_references'
6162
| 'get_deployed_workflow_state'
@@ -172,6 +173,7 @@ export interface ToolCatalogEntry {
172173
| 'generate_audio'
173174
| 'generate_image'
174175
| 'generate_video'
176+
| 'get_account_billing'
175177
| 'get_block_outputs'
176178
| 'get_block_upstream_references'
177179
| 'get_deployed_workflow_state'
@@ -2901,6 +2903,14 @@ export const GenerateVideo: ToolCatalogEntry = {
29012903
capabilities: ['file_input', 'file_output', 'generated_media'],
29022904
}
29032905

2906+
export const GetAccountBilling: ToolCatalogEntry = {
2907+
id: 'get_account_billing',
2908+
name: 'get_account_billing',
2909+
route: 'sim',
2910+
mode: 'async',
2911+
parameters: { type: 'object', properties: {} },
2912+
}
2913+
29042914
export const GetBlockOutputs: ToolCatalogEntry = {
29052915
id: 'get_block_outputs',
29062916
name: 'get_block_outputs',
@@ -5931,6 +5941,7 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
59315941
[GenerateAudio.id]: GenerateAudio,
59325942
[GenerateImage.id]: GenerateImage,
59335943
[GenerateVideo.id]: GenerateVideo,
5944+
[GetAccountBilling.id]: GetAccountBilling,
59345945
[GetBlockOutputs.id]: GetBlockOutputs,
59355946
[GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences,
59365947
[GetDeployedWorkflowState.id]: GetDeployedWorkflowState,

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2809,6 +2809,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
28092809
},
28102810
resultSchema: undefined,
28112811
},
2812+
get_account_billing: {
2813+
parameters: {
2814+
type: 'object',
2815+
properties: {},
2816+
},
2817+
resultSchema: undefined,
2818+
},
28122819
get_block_outputs: {
28132820
parameters: {
28142821
type: 'object',

apps/sim/lib/copilot/tool-executor/register-handlers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DiffWorkflows,
1313
FunctionExecute,
1414
GenerateApiKey,
15+
GetAccountBilling,
1516
GetBlockOutputs,
1617
GetBlockUpstreamReferences,
1718
GetDeployedWorkflowState,
@@ -52,6 +53,7 @@ import {
5253
} from '@/lib/copilot/generated/tool-catalog-v1'
5354
import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter'
5455
import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router'
56+
import { executeGetAccountBilling } from '../tools/handlers/account'
5557
import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block'
5658
import {
5759
executeDeployApi,
@@ -134,6 +136,7 @@ function h(fn: (params: any, context: any) => Promise<any>): ToolHandler {
134136
function buildHandlerMap(): Record<string, ToolHandler> {
135137
return {
136138
[ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)),
139+
[GetAccountBilling.id]: h((_p, c) => executeGetAccountBilling(c)),
137140
[GetWorkflowData.id]: h(executeGetWorkflowData),
138141
[GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions),
139142
[GetBlockOutputs.id]: h(executeGetBlockOutputs),
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetUserUsageData, mockGetCreditBalance, mockGetUserUsageLimitInfo } = vi.hoisted(
7+
() => ({
8+
mockGetUserUsageData: vi.fn(),
9+
mockGetCreditBalance: vi.fn(),
10+
mockGetUserUsageLimitInfo: vi.fn(),
11+
})
12+
)
13+
14+
vi.mock('@/lib/billing', () => ({
15+
getUserUsageData: mockGetUserUsageData,
16+
getCreditBalance: mockGetCreditBalance,
17+
getUserUsageLimitInfo: mockGetUserUsageLimitInfo,
18+
}))
19+
20+
import type { ExecutionContext } from '@/lib/copilot/request/types'
21+
import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account'
22+
23+
const context = { userId: 'user-1' } as ExecutionContext
24+
25+
describe('executeGetAccountBilling', () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
})
29+
30+
it('returns the org-aware plan, usage, and credit snapshot', async () => {
31+
const periodEnd = new Date('2026-09-01T00:00:00Z')
32+
mockGetUserUsageData.mockResolvedValue({
33+
currentUsage: 18.5,
34+
limit: 40,
35+
percentUsed: 46.25,
36+
isWarning: false,
37+
isExceeded: false,
38+
billingPeriodStart: new Date('2026-08-01T00:00:00Z'),
39+
billingPeriodEnd: periodEnd,
40+
lastPeriodCost: 31,
41+
})
42+
mockGetCreditBalance.mockResolvedValue({
43+
balance: 25,
44+
entityType: 'organization',
45+
entityId: 'org-1',
46+
})
47+
mockGetUserUsageLimitInfo.mockResolvedValue({
48+
currentLimit: 40,
49+
canEdit: false,
50+
minimumLimit: 0,
51+
plan: 'team',
52+
updatedAt: null,
53+
scope: 'organization',
54+
organizationId: 'org-1',
55+
})
56+
57+
const result = await executeGetAccountBilling(context)
58+
59+
expect(mockGetUserUsageData).toHaveBeenCalledWith('user-1')
60+
expect(mockGetCreditBalance).toHaveBeenCalledWith('user-1')
61+
expect(mockGetUserUsageLimitInfo).toHaveBeenCalledWith('user-1')
62+
expect(result).toEqual({
63+
success: true,
64+
output: {
65+
plan: 'team',
66+
billingScope: 'organization',
67+
organizationId: 'org-1',
68+
usage: {
69+
currentPeriodCost: 18.5,
70+
limit: 40,
71+
remaining: 21.5,
72+
percentUsed: 46.25,
73+
isExceeded: false,
74+
billingPeriodEnd: periodEnd,
75+
},
76+
credits: { balance: 25, scope: 'organization' },
77+
},
78+
})
79+
})
80+
81+
it('clamps remaining to zero when usage exceeds the limit', async () => {
82+
mockGetUserUsageData.mockResolvedValue({
83+
currentUsage: 45,
84+
limit: 40,
85+
percentUsed: 112.5,
86+
isWarning: false,
87+
isExceeded: true,
88+
billingPeriodStart: null,
89+
billingPeriodEnd: null,
90+
lastPeriodCost: 0,
91+
})
92+
mockGetCreditBalance.mockResolvedValue({ balance: 0, entityType: 'user', entityId: 'user-1' })
93+
mockGetUserUsageLimitInfo.mockResolvedValue({
94+
currentLimit: 40,
95+
canEdit: true,
96+
minimumLimit: 0,
97+
plan: 'pro',
98+
updatedAt: null,
99+
scope: 'user',
100+
organizationId: null,
101+
})
102+
103+
const result = await executeGetAccountBilling(context)
104+
105+
expect(result.success).toBe(true)
106+
expect(result.output).toMatchObject({
107+
plan: 'pro',
108+
usage: { remaining: 0, isExceeded: true },
109+
})
110+
})
111+
112+
it('surfaces a billing lookup failure as a tool error', async () => {
113+
mockGetUserUsageData.mockRejectedValue(new Error('stats row missing'))
114+
mockGetCreditBalance.mockResolvedValue({ balance: 0, entityType: 'user', entityId: 'user-1' })
115+
mockGetUserUsageLimitInfo.mockResolvedValue({})
116+
117+
const result = await executeGetAccountBilling(context)
118+
119+
expect(result).toEqual({ success: false, error: 'stats row missing' })
120+
})
121+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { toError } from '@sim/utils/errors'
2+
import { getCreditBalance, getUserUsageData, getUserUsageLimitInfo } from '@/lib/billing'
3+
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
4+
5+
/**
6+
* Live billing snapshot for the requesting user: plan, current-period usage
7+
* against its limit, and purchased credit balance. All three sources are
8+
* org-aware — a member whose subscription lives on an organization gets the
9+
* org's plan, limit, and credit pool, with `billingScope`/`organizationId`
10+
* saying which applied.
11+
*/
12+
export async function executeGetAccountBilling(context: ExecutionContext): Promise<ToolCallResult> {
13+
try {
14+
const [usage, credits, limitInfo] = await Promise.all([
15+
getUserUsageData(context.userId),
16+
getCreditBalance(context.userId),
17+
getUserUsageLimitInfo(context.userId),
18+
])
19+
20+
return {
21+
success: true,
22+
output: {
23+
plan: limitInfo.plan,
24+
billingScope: limitInfo.scope,
25+
organizationId: limitInfo.organizationId,
26+
usage: {
27+
currentPeriodCost: usage.currentUsage,
28+
limit: usage.limit,
29+
remaining: Math.max(0, usage.limit - usage.currentUsage),
30+
percentUsed: usage.percentUsed,
31+
isExceeded: usage.isExceeded,
32+
billingPeriodEnd: usage.billingPeriodEnd,
33+
},
34+
credits: {
35+
balance: credits.balance,
36+
scope: credits.entityType,
37+
},
38+
},
39+
}
40+
} catch (error) {
41+
return { success: false, error: toError(error).message }
42+
}
43+
}

apps/sim/lib/copilot/tools/tool-display.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ const TOOL_TITLES: Record<string, string> = {
469469
function_execute: 'Running code',
470470
complete_scheduled_task: 'Completing scheduled task',
471471
generate_api_key: 'Generating API key',
472+
get_account_billing: 'Checking plan and usage',
472473
get_block_outputs: 'Getting block outputs',
473474
get_block_upstream_references: 'Getting block references',
474475
get_deployed_workflow_state: 'Getting deployed workflow',

0 commit comments

Comments
 (0)