Skip to content

Commit 3498b24

Browse files
fix(agent): pass through billing attribution to tools (#5698)
* fix(agent): pass through billing attribution to tools * tests * fix formatting * address comments
1 parent bfe8386 commit 3498b24

8 files changed

Lines changed: 303 additions & 8 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockExecuteProviderRequest,
9+
mockRequireBillingAttributionHeader,
10+
mockCheckWorkspaceAccess,
11+
mockAuthorizeCredentialUse,
12+
} = vi.hoisted(() => ({
13+
mockExecuteProviderRequest: vi.fn(),
14+
mockRequireBillingAttributionHeader: vi.fn(),
15+
mockCheckWorkspaceAccess: vi.fn(),
16+
mockAuthorizeCredentialUse: vi.fn(),
17+
}))
18+
19+
vi.mock('@/providers', () => ({
20+
executeProviderRequest: mockExecuteProviderRequest,
21+
}))
22+
23+
vi.mock('@/lib/billing/core/billing-attribution', () => ({
24+
BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution',
25+
requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
26+
}))
27+
28+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
29+
checkWorkspaceAccess: mockCheckWorkspaceAccess,
30+
}))
31+
32+
vi.mock('@/lib/auth/credential-access', () => ({
33+
authorizeCredentialUse: mockAuthorizeCredentialUse,
34+
}))
35+
36+
vi.mock('@/app/api/auth/oauth/utils', () => ({
37+
getServiceAccountToken: vi.fn(),
38+
refreshTokenIfNeeded: vi.fn(),
39+
resolveOAuthAccountId: vi.fn(),
40+
}))
41+
42+
vi.mock('@/ee/access-control/utils/permission-check', () => ({
43+
assertPermissionsAllowed: vi.fn(),
44+
IntegrationNotAllowedError: class IntegrationNotAllowedError extends Error {},
45+
ModelNotAllowedError: class ModelNotAllowedError extends Error {},
46+
ProviderNotAllowedError: class ProviderNotAllowedError extends Error {},
47+
}))
48+
49+
import { POST } from '@/app/api/providers/route'
50+
51+
const BILLING_ATTRIBUTION = {
52+
actorUserId: 'user-1',
53+
workspaceId: 'ws-1',
54+
organizationId: 'org-1',
55+
billedAccountUserId: 'owner-1',
56+
billingEntity: { type: 'organization', id: 'org-1' },
57+
billingPeriod: {
58+
start: '2026-07-01T00:00:00.000Z',
59+
end: '2026-08-01T00:00:00.000Z',
60+
},
61+
payerSubscription: null,
62+
}
63+
64+
describe('POST /api/providers', () => {
65+
beforeEach(() => {
66+
vi.clearAllMocks()
67+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
68+
success: true,
69+
userId: 'user-1',
70+
authType: 'internal_jwt',
71+
})
72+
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
73+
mockRequireBillingAttributionHeader.mockReturnValue(BILLING_ATTRIBUTION)
74+
mockExecuteProviderRequest.mockResolvedValue({
75+
content: 'hello',
76+
model: 'gpt-4o',
77+
tokens: { input: 1, output: 1, total: 2 },
78+
})
79+
})
80+
81+
it('validates the attribution header and forwards it to executeProviderRequest', async () => {
82+
const res = await POST(
83+
createMockRequest(
84+
'POST',
85+
{ provider: 'openai', model: 'gpt-4o', workspaceId: 'ws-1' },
86+
{ 'x-sim-billing-attribution': 'encoded-attribution' }
87+
)
88+
)
89+
90+
expect(res.status).toBe(200)
91+
expect(mockRequireBillingAttributionHeader).toHaveBeenCalledWith(expect.anything(), {
92+
actorUserId: 'user-1',
93+
workspaceId: 'ws-1',
94+
})
95+
expect(mockExecuteProviderRequest).toHaveBeenCalledWith(
96+
'openai',
97+
expect.objectContaining({ billingAttribution: BILLING_ATTRIBUTION })
98+
)
99+
})
100+
101+
it('executes without attribution when the header is absent', async () => {
102+
const res = await POST(
103+
createMockRequest('POST', { provider: 'openai', model: 'gpt-4o', workspaceId: 'ws-1' })
104+
)
105+
106+
expect(res.status).toBe(200)
107+
expect(mockRequireBillingAttributionHeader).not.toHaveBeenCalled()
108+
expect(mockExecuteProviderRequest).toHaveBeenCalledWith(
109+
'openai',
110+
expect.objectContaining({ billingAttribution: undefined })
111+
)
112+
})
113+
114+
it('rejects an attribution header when the body has no workspaceId to validate against', async () => {
115+
const res = await POST(
116+
createMockRequest(
117+
'POST',
118+
{ provider: 'openai', model: 'gpt-4o' },
119+
{ 'x-sim-billing-attribution': 'encoded-attribution' }
120+
)
121+
)
122+
123+
expect(res.status).toBe(400)
124+
expect(mockRequireBillingAttributionHeader).not.toHaveBeenCalled()
125+
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
126+
})
127+
128+
it('rejects with 400 when the attribution header does not match the authenticated scope', async () => {
129+
mockRequireBillingAttributionHeader.mockImplementation(() => {
130+
throw new Error('Billing attribution header does not match the authenticated request scope')
131+
})
132+
133+
const res = await POST(
134+
createMockRequest(
135+
'POST',
136+
{ provider: 'openai', model: 'gpt-4o', workspaceId: 'ws-1' },
137+
{ 'x-sim-billing-attribution': 'encoded-attribution' }
138+
)
139+
)
140+
141+
expect(res.status).toBe(400)
142+
const body = await res.json()
143+
expect(body.error).toBe(
144+
'Billing attribution header does not match the authenticated request scope'
145+
)
146+
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
147+
})
148+
})

apps/sim/app/api/providers/route.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import { executeProviderContract } from '@/lib/api/contracts/providers'
88
import { parseRequest } from '@/lib/api/server'
99
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
1010
import { checkInternalAuth } from '@/lib/auth/hybrid'
11+
import {
12+
BILLING_ATTRIBUTION_HEADER,
13+
type BillingAttributionSnapshot,
14+
requireBillingAttributionHeader,
15+
} from '@/lib/billing/core/billing-attribution'
1116
import { generateRequestId } from '@/lib/core/utils/request'
1217
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1318
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -177,11 +182,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
177182
)
178183
}
179184

185+
/**
186+
* Nested tool calls made by the LLM (e.g. knowledge search) hit internal
187+
* routes that require the upstream billing decision. This route is
188+
* internal-JWT-only, so the caller's attribution arrives as a header;
189+
* validate it against the authenticated scope and thread it through. A
190+
* header the route cannot validate is a caller protocol error (400), never
191+
* silently dropped.
192+
*/
193+
let billingAttribution: BillingAttributionSnapshot | undefined
194+
if (request.headers.get(BILLING_ATTRIBUTION_HEADER)) {
195+
if (!workspaceId) {
196+
return NextResponse.json(
197+
{ error: 'workspaceId is required when billing attribution is supplied' },
198+
{ status: 400 }
199+
)
200+
}
201+
try {
202+
billingAttribution = requireBillingAttributionHeader(request.headers, {
203+
actorUserId: auth.userId,
204+
workspaceId,
205+
})
206+
} catch (error) {
207+
return NextResponse.json(
208+
{ error: getErrorMessage(error, 'Invalid billing attribution header') },
209+
{ status: 400 }
210+
)
211+
}
212+
}
213+
180214
logger.info(`[${requestId}] Executing provider request`, {
181215
provider,
182216
model,
183217
workflowId,
184218
hasApiKey: !!finalApiKey,
219+
hasBillingAttribution: !!billingAttribution,
185220
})
186221

187222
const response = await executeProviderRequest(provider, {
@@ -209,6 +244,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
209244
workflowVariables,
210245
blockData,
211246
blockNameMapping,
247+
billingAttribution,
212248
reasoningEffort,
213249
verbosity,
214250
})

apps/sim/executor/handlers/agent/agent-handler.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,6 +1828,42 @@ describe('AgentBlockHandler', () => {
18281828
expect(providerCallArgs.callChain).toEqual(['wf-parent', 'test-workflow-456'])
18291829
})
18301830

1831+
it('should pass billingAttribution to executeProviderRequest so LLM tool calls carry it', async () => {
1832+
const billingAttribution = {
1833+
actorUserId: 'user-1',
1834+
workspaceId: 'test-workspace-123',
1835+
organizationId: 'organization-1',
1836+
billedAccountUserId: 'owner-1',
1837+
billingEntity: { type: 'organization', id: 'organization-1' },
1838+
billingPeriod: {
1839+
start: '2026-07-01T00:00:00.000Z',
1840+
end: '2026-08-01T00:00:00.000Z',
1841+
},
1842+
payerSubscription: null,
1843+
}
1844+
1845+
const inputs = {
1846+
model: 'gpt-4o',
1847+
userPrompt: 'Search the knowledge base',
1848+
apiKey: 'test-api-key',
1849+
}
1850+
1851+
const contextWithAttribution = {
1852+
...mockContext,
1853+
workspaceId: 'test-workspace-123',
1854+
workflowId: 'test-workflow-456',
1855+
metadata: { ...mockContext.metadata, billingAttribution },
1856+
} as ExecutionContext
1857+
1858+
mockGetProviderFromModel.mockReturnValue('openai')
1859+
1860+
await handler.execute(contextWithAttribution, mockBlock, inputs)
1861+
1862+
expect(mockExecuteProviderRequest).toHaveBeenCalled()
1863+
const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1]
1864+
expect(providerCallArgs.billingAttribution).toEqual(billingAttribution)
1865+
})
1866+
18311867
it('should handle multiple MCP tools from the same server efficiently', async () => {
18321868
const fetchCalls: any[] = []
18331869

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,7 @@ export class AgentBlockHandler implements BlockHandler {
10211021
blockNameMapping,
10221022
isDeployedContext: ctx.isDeployedContext,
10231023
callChain: ctx.callChain,
1024+
billingAttribution: ctx.metadata.billingAttribution,
10241025
reasoningEffort: providerRequest.reasoningEffort,
10251026
verbosity: providerRequest.verbosity,
10261027
thinkingLevel: providerRequest.thinkingLevel,

apps/sim/providers/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
12
import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types'
23

34
export type ProviderId =
@@ -185,6 +186,12 @@ export interface ProviderRequest {
185186
thinkingLevel?: string
186187
isDeployedContext?: boolean
187188
callChain?: string[]
189+
/**
190+
* Immutable actor/payer decision captured before execution. Propagated into
191+
* the `_context` of every tool the LLM invokes so internal routes that
192+
* require the billing attribution header (e.g. knowledge search) receive it.
193+
*/
194+
billingAttribution?: BillingAttributionSnapshot
188195
/** Previous interaction ID for multi-turn Interactions API requests (deep research follow-ups) */
189196
previousInteractionId?: string
190197
abortSignal?: AbortSignal

apps/sim/providers/utils.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,67 @@ describe('prepareToolExecution', () => {
12901290
})
12911291
})
12921292

1293+
describe('_context propagation', () => {
1294+
const billingAttribution = {
1295+
actorUserId: 'user-1',
1296+
workspaceId: 'workspace-1',
1297+
organizationId: 'organization-1',
1298+
billedAccountUserId: 'owner-1',
1299+
billingEntity: { type: 'organization' as const, id: 'organization-1' },
1300+
billingPeriod: {
1301+
start: '2026-07-01T00:00:00.000Z',
1302+
end: '2026-08-01T00:00:00.000Z',
1303+
},
1304+
payerSubscription: null,
1305+
}
1306+
1307+
it.concurrent(
1308+
'should include billingAttribution in _context when the request carries it',
1309+
() => {
1310+
const tool = { params: {} }
1311+
const request = {
1312+
workflowId: 'wf-123',
1313+
workspaceId: 'workspace-1',
1314+
userId: 'user-1',
1315+
billingAttribution,
1316+
}
1317+
1318+
const { executionParams } = prepareToolExecution(tool, {}, request)
1319+
1320+
expect(executionParams._context.billingAttribution).toEqual(billingAttribution)
1321+
}
1322+
)
1323+
1324+
it.concurrent('should omit billingAttribution from _context when the request lacks it', () => {
1325+
const tool = { params: {} }
1326+
const request = { workflowId: 'wf-123', workspaceId: 'workspace-1' }
1327+
1328+
const { executionParams } = prepareToolExecution(tool, {}, request)
1329+
1330+
expect(executionParams._context).toBeDefined()
1331+
expect(executionParams._context).not.toHaveProperty('billingAttribution')
1332+
})
1333+
1334+
it.concurrent('should carry billingAttribution even when the request has no workflowId', () => {
1335+
const tool = { params: {} }
1336+
const request = { workspaceId: 'workspace-1', billingAttribution }
1337+
1338+
const { executionParams } = prepareToolExecution(tool, {}, request)
1339+
1340+
expect(executionParams._context.billingAttribution).toEqual(billingAttribution)
1341+
expect(executionParams._context.workspaceId).toBe('workspace-1')
1342+
expect(executionParams._context).not.toHaveProperty('workflowId')
1343+
})
1344+
1345+
it.concurrent('should not build _context when there is no workflowId or attribution', () => {
1346+
const tool = { params: {} }
1347+
1348+
const { executionParams } = prepareToolExecution(tool, {}, { workspaceId: 'workspace-1' })
1349+
1350+
expect(executionParams).not.toHaveProperty('_context')
1351+
})
1352+
})
1353+
12931354
describe('inputMapping deep merge for workflow tools', () => {
12941355
it.concurrent('should deep merge inputMapping when user provides empty object', () => {
12951356
const tool = {

apps/sim/providers/utils.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { omit } from '@sim/utils/object'
44
import type OpenAI from 'openai'
55
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
66
import type { CompletionUsage } from 'openai/resources/completions'
7+
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
78
import { formatCreditCost } from '@/lib/billing/credits/conversion'
89
import { env } from '@/lib/core/config/env'
910
import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/env-flags'
@@ -1293,6 +1294,7 @@ export function prepareToolExecution(
12931294
blockNameMapping?: Record<string, string>
12941295
isDeployedContext?: boolean
12951296
callChain?: string[]
1297+
billingAttribution?: BillingAttributionSnapshot
12961298
}
12971299
): {
12981300
toolParams: Record<string, any>
@@ -1310,17 +1312,20 @@ export function prepareToolExecution(
13101312

13111313
const executionParams = {
13121314
...toolParams,
1313-
...(request.workflowId
1315+
...(request.workflowId || request.billingAttribution
13141316
? {
13151317
_context: {
1316-
workflowId: request.workflowId,
1318+
...(request.workflowId ? { workflowId: request.workflowId } : {}),
13171319
...(request.workspaceId ? { workspaceId: request.workspaceId } : {}),
13181320
...(request.chatId ? { chatId: request.chatId } : {}),
13191321
...(request.userId ? { userId: request.userId } : {}),
13201322
...(request.isDeployedContext !== undefined
13211323
? { isDeployedContext: request.isDeployedContext }
13221324
: {}),
13231325
...(request.callChain ? { callChain: request.callChain } : {}),
1326+
...(request.billingAttribution
1327+
? { billingAttribution: request.billingAttribution }
1328+
: {}),
13241329
},
13251330
}
13261331
: {}),

0 commit comments

Comments
 (0)