diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 10e78383c7c..b3cb29d508c 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -62,6 +62,55 @@ describe('copilot tool executor fallback', () => { expect(result).toEqual({ success: true, output: { emails: [] } }) }) + it('threads billing attribution into _context for dynamic tools (MCP)', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: {} }) + + const billingAttribution = { + actorUserId: 'user-1', + workspaceId: 'ws-1', + organizationId: null, + billedAccountUserId: 'owner-1', + billingEntity: { type: 'user', id: 'owner-1' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + payerSubscription: null, + } + + await executeTool( + 'mcp-server-1-web_search_exa', + { query: 'test' }, + { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + billingAttribution: billingAttribution as never, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'mcp-server-1-web_search_exa', + expect.objectContaining({ + _context: expect.objectContaining({ + userId: 'user-1', + workspaceId: 'ws-1', + billingAttribution, + }), + }) + ) + }) + + it('omits billingAttribution from _context when the context has none', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: {} }) + + await executeTool('gmail_read', {}, { userId: 'user-1', workflowId: 'workflow-1' }) + + const appParams = executeAppTool.mock.calls[0][1] as Record + expect(appParams._context).not.toHaveProperty('billingAttribution') + }) + it('uses the registered handler for client-routed tools when running headless (Mothership block)', async () => { isKnownTool.mockReturnValue(true) isSimExecuted.mockReturnValue(false) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 476f29c93a2..0a5323f5e16 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -161,6 +161,7 @@ function buildAppToolParams( requestMode: context.requestMode, currentAgentId: context.currentAgentId, enforceCredentialAccess: true, + ...(context.billingAttribution ? { billingAttribution: context.billingAttribution } : {}), } return result diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 1f5a13b5737..ccf6b097253 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -2973,6 +2973,130 @@ describe('Cost Field Handling', () => { Object.assign(tools, originalTools) }) + it('emits _serviceCost for copilot executions using a hosted key', async () => { + const mockTool = { + id: 'test_copilot_hosted_cost', + name: 'Test Copilot Hosted Cost', + description: 'A hosted-key tool invoked by the copilot', + version: '1.0.0', + params: { + apiKey: { type: 'string', required: false }, + }, + hosting: { + envKeyPrefix: 'TEST_HOSTED_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'exa', + pricing: { + type: 'per_request' as const, + cost: 0.005, + }, + rateLimit: { + mode: 'per_request' as const, + requestsPerMinute: 100, + }, + }, + request: { + url: '/api/test/copilot-cost', + method: 'POST' as const, + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'success' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_copilot_hosted_cost = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ success: true }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + // Copilot flow: no executionContext option; scope comes from _context, + // which the copilot tool-executor stamps with copilotToolExecution. + const result = await executeTool('test_copilot_hosted_cost', { + _context: { + userId: 'user-123', + workspaceId: 'workspace-456', + copilotToolExecution: true, + }, + }) + + expect(result.success).toBe(true) + expect(result.output.cost).toEqual({ total: 0.005 }) + expect(result.output._serviceCost).toEqual({ service: 'exa', cost: 0.005 }) + + Object.assign(tools, originalTools) + }) + + it('does not emit _serviceCost for workflow executions using a hosted key', async () => { + const mockTool = { + id: 'test_workflow_hosted_cost', + name: 'Test Workflow Hosted Cost', + description: 'A hosted-key tool invoked by a workflow', + version: '1.0.0', + params: { + apiKey: { type: 'string', required: false }, + }, + hosting: { + envKeyPrefix: 'TEST_HOSTED_KEY', + apiKeyParam: 'apiKey', + pricing: { + type: 'per_request' as const, + cost: 0.005, + }, + rateLimit: { + mode: 'per_request' as const, + requestsPerMinute: 100, + }, + }, + request: { + url: '/api/test/workflow-cost', + method: 'POST' as const, + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'success' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_workflow_hosted_cost = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ success: true }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + const mockContext = createToolExecutionContext({ userId: 'user-123' } as any) + const result = await executeTool( + 'test_workflow_hosted_cost', + {}, + { executionContext: mockContext } + ) + + expect(result.success).toBe(true) + // Workflow executions bill through the execution ledger; emitting + // _serviceCost here would double-bill via Go's service-charge path. + expect(result.output.cost).toEqual({ total: 0.005 }) + expect(result.output._serviceCost).toBeUndefined() + + Object.assign(tools, originalTools) + }) + it('should use custom pricing getCost function', async () => { const mockGetCost = vi.fn().mockReturnValue({ cost: 0.015, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 80dee08c89b..f4b9c3fc241 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -709,6 +709,15 @@ export function postProcessToolOutput(toolId: string, output: Record 0) { + const { copilotToolExecution } = resolveToolScope(params, executionContext) finalResult.output = { ...finalResult.output, cost: { ...metadata, total: hostedKeyCost, }, + // Copilot-only: workflow runs must not emit _serviceCost or the cost + // would be billed twice (execution ledger + Go service charge). + ...(copilotToolExecution ? { _serviceCost: { service: provider, cost: hostedKeyCost } } : {}), } } } diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index ac45f63ef4c..a1d9b3f76a4 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -19,6 +19,10 @@ interface LLMChatParams { bedrockAccessKeyId?: string bedrockSecretKey?: string bedrockRegion?: string + _context?: { + workspaceId?: string + workflowId?: string + } } interface LLMChatResponse extends ToolResponse { @@ -146,6 +150,11 @@ export const llmChatTool: ToolConfig = { bedrockAccessKeyId: params.bedrockAccessKeyId, bedrockSecretKey: params.bedrockSecretKey, bedrockRegion: params.bedrockRegion, + // The executor attaches the billing-attribution header on internal + // routes whenever the execution scope carries one, and /api/providers + // rejects that header unless the body names the workspace it is + // validated against. + ...(params._context?.workspaceId ? { workspaceId: params._context.workspaceId } : {}), } }, }, diff --git a/bun.lock b/bun.lock index 7e81dd4365f..f3ad75a78dd 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -3086,7 +3085,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4462,6 +4461,10 @@ "@sim/db/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], + "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + + "@sim/workflow-renderer/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4660,14 +4663,10 @@ "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], - "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], - "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4822,6 +4821,8 @@ "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],