Skip to content

Commit 3453692

Browse files
fix(mothership): billing for mcps, hosted key tools (#5740)
* fix(mothership): attribution for direct exec tools * fix hosted key charges for direct tool exec * fix llm chat tool
1 parent 5eafa86 commit 3453692

6 files changed

Lines changed: 203 additions & 6 deletions

File tree

apps/sim/lib/copilot/tool-executor/executor.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,55 @@ describe('copilot tool executor fallback', () => {
6262
expect(result).toEqual({ success: true, output: { emails: [] } })
6363
})
6464

65+
it('threads billing attribution into _context for dynamic tools (MCP)', async () => {
66+
isKnownTool.mockReturnValue(false)
67+
isSimExecuted.mockReturnValue(false)
68+
executeAppTool.mockResolvedValue({ success: true, output: {} })
69+
70+
const billingAttribution = {
71+
actorUserId: 'user-1',
72+
workspaceId: 'ws-1',
73+
organizationId: null,
74+
billedAccountUserId: 'owner-1',
75+
billingEntity: { type: 'user', id: 'owner-1' },
76+
billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' },
77+
payerSubscription: null,
78+
}
79+
80+
await executeTool(
81+
'mcp-server-1-web_search_exa',
82+
{ query: 'test' },
83+
{
84+
userId: 'user-1',
85+
workflowId: '',
86+
workspaceId: 'ws-1',
87+
billingAttribution: billingAttribution as never,
88+
}
89+
)
90+
91+
expect(executeAppTool).toHaveBeenCalledWith(
92+
'mcp-server-1-web_search_exa',
93+
expect.objectContaining({
94+
_context: expect.objectContaining({
95+
userId: 'user-1',
96+
workspaceId: 'ws-1',
97+
billingAttribution,
98+
}),
99+
})
100+
)
101+
})
102+
103+
it('omits billingAttribution from _context when the context has none', async () => {
104+
isKnownTool.mockReturnValue(false)
105+
isSimExecuted.mockReturnValue(false)
106+
executeAppTool.mockResolvedValue({ success: true, output: {} })
107+
108+
await executeTool('gmail_read', {}, { userId: 'user-1', workflowId: 'workflow-1' })
109+
110+
const appParams = executeAppTool.mock.calls[0][1] as Record<string, unknown>
111+
expect(appParams._context).not.toHaveProperty('billingAttribution')
112+
})
113+
65114
it('uses the registered handler for client-routed tools when running headless (Mothership block)', async () => {
66115
isKnownTool.mockReturnValue(true)
67116
isSimExecuted.mockReturnValue(false)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ function buildAppToolParams(
161161
requestMode: context.requestMode,
162162
currentAgentId: context.currentAgentId,
163163
enforceCredentialAccess: true,
164+
...(context.billingAttribution ? { billingAttribution: context.billingAttribution } : {}),
164165
}
165166

166167
return result

apps/sim/tools/index.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2973,6 +2973,130 @@ describe('Cost Field Handling', () => {
29732973
Object.assign(tools, originalTools)
29742974
})
29752975

2976+
it('emits _serviceCost for copilot executions using a hosted key', async () => {
2977+
const mockTool = {
2978+
id: 'test_copilot_hosted_cost',
2979+
name: 'Test Copilot Hosted Cost',
2980+
description: 'A hosted-key tool invoked by the copilot',
2981+
version: '1.0.0',
2982+
params: {
2983+
apiKey: { type: 'string', required: false },
2984+
},
2985+
hosting: {
2986+
envKeyPrefix: 'TEST_HOSTED_KEY',
2987+
apiKeyParam: 'apiKey',
2988+
byokProviderId: 'exa',
2989+
pricing: {
2990+
type: 'per_request' as const,
2991+
cost: 0.005,
2992+
},
2993+
rateLimit: {
2994+
mode: 'per_request' as const,
2995+
requestsPerMinute: 100,
2996+
},
2997+
},
2998+
request: {
2999+
url: '/api/test/copilot-cost',
3000+
method: 'POST' as const,
3001+
headers: () => ({ 'Content-Type': 'application/json' }),
3002+
},
3003+
transformResponse: vi.fn().mockResolvedValue({
3004+
success: true,
3005+
output: { result: 'success' },
3006+
}),
3007+
}
3008+
3009+
const originalTools = { ...tools }
3010+
;(tools as any).test_copilot_hosted_cost = mockTool
3011+
3012+
global.fetch = Object.assign(
3013+
vi.fn().mockImplementation(async () => ({
3014+
ok: true,
3015+
status: 200,
3016+
headers: new Headers(),
3017+
json: () => Promise.resolve({ success: true }),
3018+
})),
3019+
{ preconnect: vi.fn() }
3020+
) as typeof fetch
3021+
3022+
// Copilot flow: no executionContext option; scope comes from _context,
3023+
// which the copilot tool-executor stamps with copilotToolExecution.
3024+
const result = await executeTool('test_copilot_hosted_cost', {
3025+
_context: {
3026+
userId: 'user-123',
3027+
workspaceId: 'workspace-456',
3028+
copilotToolExecution: true,
3029+
},
3030+
})
3031+
3032+
expect(result.success).toBe(true)
3033+
expect(result.output.cost).toEqual({ total: 0.005 })
3034+
expect(result.output._serviceCost).toEqual({ service: 'exa', cost: 0.005 })
3035+
3036+
Object.assign(tools, originalTools)
3037+
})
3038+
3039+
it('does not emit _serviceCost for workflow executions using a hosted key', async () => {
3040+
const mockTool = {
3041+
id: 'test_workflow_hosted_cost',
3042+
name: 'Test Workflow Hosted Cost',
3043+
description: 'A hosted-key tool invoked by a workflow',
3044+
version: '1.0.0',
3045+
params: {
3046+
apiKey: { type: 'string', required: false },
3047+
},
3048+
hosting: {
3049+
envKeyPrefix: 'TEST_HOSTED_KEY',
3050+
apiKeyParam: 'apiKey',
3051+
pricing: {
3052+
type: 'per_request' as const,
3053+
cost: 0.005,
3054+
},
3055+
rateLimit: {
3056+
mode: 'per_request' as const,
3057+
requestsPerMinute: 100,
3058+
},
3059+
},
3060+
request: {
3061+
url: '/api/test/workflow-cost',
3062+
method: 'POST' as const,
3063+
headers: () => ({ 'Content-Type': 'application/json' }),
3064+
},
3065+
transformResponse: vi.fn().mockResolvedValue({
3066+
success: true,
3067+
output: { result: 'success' },
3068+
}),
3069+
}
3070+
3071+
const originalTools = { ...tools }
3072+
;(tools as any).test_workflow_hosted_cost = mockTool
3073+
3074+
global.fetch = Object.assign(
3075+
vi.fn().mockImplementation(async () => ({
3076+
ok: true,
3077+
status: 200,
3078+
headers: new Headers(),
3079+
json: () => Promise.resolve({ success: true }),
3080+
})),
3081+
{ preconnect: vi.fn() }
3082+
) as typeof fetch
3083+
3084+
const mockContext = createToolExecutionContext({ userId: 'user-123' } as any)
3085+
const result = await executeTool(
3086+
'test_workflow_hosted_cost',
3087+
{},
3088+
{ executionContext: mockContext }
3089+
)
3090+
3091+
expect(result.success).toBe(true)
3092+
// Workflow executions bill through the execution ledger; emitting
3093+
// _serviceCost here would double-bill via Go's service-charge path.
3094+
expect(result.output.cost).toEqual({ total: 0.005 })
3095+
expect(result.output._serviceCost).toBeUndefined()
3096+
3097+
Object.assign(tools, originalTools)
3098+
})
3099+
29763100
it('should use custom pricing getCost function', async () => {
29773101
const mockGetCost = vi.fn().mockReturnValue({
29783102
cost: 0.015,

apps/sim/tools/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,15 @@ export function postProcessToolOutput(toolId: string, output: Record<string, unk
709709
/**
710710
* Apply post-execution hosted-key cost tracking to a successful tool result.
711711
* Reports custom dimension usage, calculates cost, and merges it into the output.
712+
*
713+
* Billing capture differs by caller:
714+
* - Workflow executions bill `output.cost.total` through trace spans and the
715+
* execution ledger (`recordUsage`), so the `cost` field alone suffices.
716+
* - Copilot tool executions have no execution ledger. Their only billing hook
717+
* is Go's `extractServiceCost`, which reads a top-level `_serviceCost` field
718+
* from the tool result and charges it through the per-round update-cost
719+
* callback (the same path the media tools use). Without it, hosted-key spend
720+
* from copilot-dispatched integration tools is never charged.
712721
*/
713722
async function applyHostedKeyCostToResult(
714723
finalResult: ToolResponse,
@@ -734,12 +743,16 @@ async function applyHostedKeyCostToResult(
734743
hostedKeyMetrics.recordCostCharged(hostedKeyCost, { provider, tool: tool.id })
735744

736745
if (hostedKeyCost > 0) {
746+
const { copilotToolExecution } = resolveToolScope(params, executionContext)
737747
finalResult.output = {
738748
...finalResult.output,
739749
cost: {
740750
...metadata,
741751
total: hostedKeyCost,
742752
},
753+
// Copilot-only: workflow runs must not emit _serviceCost or the cost
754+
// would be billed twice (execution ledger + Go service charge).
755+
...(copilotToolExecution ? { _serviceCost: { service: provider, cost: hostedKeyCost } } : {}),
743756
}
744757
}
745758
}

apps/sim/tools/llm/chat.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ interface LLMChatParams {
1919
bedrockAccessKeyId?: string
2020
bedrockSecretKey?: string
2121
bedrockRegion?: string
22+
_context?: {
23+
workspaceId?: string
24+
workflowId?: string
25+
}
2226
}
2327

2428
interface LLMChatResponse extends ToolResponse {
@@ -146,6 +150,11 @@ export const llmChatTool: ToolConfig<LLMChatParams, LLMChatResponse> = {
146150
bedrockAccessKeyId: params.bedrockAccessKeyId,
147151
bedrockSecretKey: params.bedrockSecretKey,
148152
bedrockRegion: params.bedrockRegion,
153+
// The executor attaches the billing-attribution header on internal
154+
// routes whenever the execution scope carries one, and /api/providers
155+
// rejects that header unless the body names the workspace it is
156+
// validated against.
157+
...(params._context?.workspaceId ? { workspaceId: params._context.workspaceId } : {}),
149158
}
150159
},
151160
},

bun.lock

Lines changed: 7 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)