Skip to content

Commit 4c6be10

Browse files
authored
fix(tools): check usage limits before running hosted-key tools via the API (#8602)
* fix(tools): check usage limits before running hosted-key tools via the API * fix(tools): gate hosted-key calls whose key is an unresolved variable reference * fix(tools): exempt variable references that resolve to the caller's own key * fix(tools): resolve the key reference with the registry's own resolver * fix(tools): decide hosted-key admission on registry-resolved params
1 parent cbf6815 commit 4c6be10

7 files changed

Lines changed: 207 additions & 12 deletions

File tree

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4601,7 +4601,7 @@
46014601
"post": {
46024602
"operationId": "executeTool",
46034603
"summary": "Run Tool",
4604-
"description": "Run a built-in tool using published parameter IDs. Sim resolves `credentialId`, hosted keys, and whole-value `{{VAR_NAME}}` references for `user-only` parameters; other values pass through verbatim. Third-party refusal returns `200` with `status: \"failed\"`; the error envelope covers API failures. Hidden or missing tools return `404`; disallowed integrations return `403` with `error.details.code: INTEGRATION_NOT_ALLOWED`. Hosted-key use is billed to the workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
4604+
"description": "Run a built-in tool using published parameter IDs. Sim resolves `credentialId`, hosted keys, and whole-value `{{VAR_NAME}}` references for `user-only` parameters; other values pass through verbatim. Third-party refusal returns `200` with `status: \"failed\"`; the error envelope covers API failures. Hidden or missing tools return `404`; disallowed integrations return `403` with `error.details.code: INTEGRATION_NOT_ALLOWED`. Hosted-key use is billed to the workspace; a call that would use a hosted key from a workspace over its usage or billing limits returns `402`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
46054605
"x-sim-operation": "tools.execute",
46064606
"x-oauth-scope": "api:write",
46074607
"tags": ["Catalog"],
@@ -4658,6 +4658,9 @@
46584658
"401": {
46594659
"$ref": "#/components/responses/Unauthorized"
46604660
},
4661+
"402": {
4662+
"$ref": "#/components/responses/UsageLimitExceeded"
4663+
},
46614664
"403": {
46624665
"$ref": "#/components/responses/Forbidden"
46634666
},
@@ -9192,6 +9195,22 @@
91929195
}
91939196
}
91949197
},
9198+
"UsageLimitExceeded": {
9199+
"description": "The workspace has exceeded its usage or billing limits.",
9200+
"content": {
9201+
"application/json": {
9202+
"schema": {
9203+
"$ref": "#/components/schemas/V2Error"
9204+
},
9205+
"example": {
9206+
"error": {
9207+
"code": "USAGE_LIMIT_EXCEEDED",
9208+
"message": "Usage limit exceeded. Please upgrade your plan to continue."
9209+
}
9210+
}
9211+
}
9212+
}
9213+
},
91959214
"Forbidden": {
91969215
"description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.",
91979216
"content": {

‎apps/sim/app/api/v2/tools/[toolId]/execute/route.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
1414
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
1515
vi.mock('@/lib/tool-execution/application/execute-tool', () => ({
1616
executeToolForCaller: { operation: { id: 'tools.execute' }, execute: mocks.execute },
17+
ToolUsageLimitExceededError: class ToolUsageLimitExceededError extends Error {},
1718
}))
1819

20+
import { ToolUsageLimitExceededError } from '@/lib/tool-execution/application/execute-tool'
1921
import { POST } from '@/app/api/v2/tools/[toolId]/execute/route'
2022

2123
const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555'
@@ -76,6 +78,15 @@ describe('POST /api/v2/tools/{toolId}/execute', () => {
7678
expect(body.data.error.message).toBe('Firecrawl returned 402')
7779
})
7880

81+
it('answers 402 when the workspace is over its usage limit', async () => {
82+
mocks.execute.mockRejectedValue(new ToolUsageLimitExceededError('Usage limit exceeded'))
83+
84+
const response = await post({ workspaceId: WORKSPACE_ID })
85+
86+
expect(response.status).toBe(402)
87+
expect((await response.json()).error.code).toBe('USAGE_LIMIT_EXCEEDED')
88+
})
89+
7990
it('rejects a timeout beyond the ceiling', async () => {
8091
const response = await post({ workspaceId: WORKSPACE_ID, timeoutSeconds: 100_000 })
8192

‎apps/sim/app/api/v2/tools/[toolId]/execute/route.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
11
import { v2ExecuteToolContract } from '@/lib/api/contracts/v2/catalog'
2-
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
3-
import { executeToolForCaller } from '@/lib/tool-execution/application/execute-tool'
2+
import {
3+
defineV2JsonRoute,
4+
type V2ErrorPolicy,
5+
v2ApiKeyAuth,
6+
v2RateLimits,
7+
} from '@/lib/api/server/routes'
8+
import {
9+
executeToolForCaller,
10+
ToolUsageLimitExceededError,
11+
} from '@/lib/tool-execution/application/execute-tool'
412
import { toolExecutionOperations } from '@/lib/tool-execution/application/operations'
513
import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog'
14+
import { v2Error } from '@/app/api/v2/lib/response'
615

716
export const dynamic = 'force-dynamic'
817
export const revalidate = 0
918

19+
/** {@link catalogErrorPolicy} plus the `402` a hosted-key call over its usage limit raises. */
20+
const executeToolErrorPolicy = {
21+
render(error) {
22+
if (error instanceof ToolUsageLimitExceededError) {
23+
return v2Error('USAGE_LIMIT_EXCEEDED', error.message)
24+
}
25+
return catalogErrorPolicy.render(error)
26+
},
27+
} satisfies V2ErrorPolicy
28+
1029
/**
1130
* POST /api/v2/tools/{toolId}/execute — Run one built-in tool.
1231
*
@@ -20,7 +39,7 @@ export const POST = defineV2JsonRoute({
2039
operation: toolExecutionOperations.execute,
2140
auth: v2ApiKeyAuth,
2241
rateLimit: v2RateLimits.publicApi,
23-
errorPolicy: catalogErrorPolicy,
42+
errorPolicy: executeToolErrorPolicy,
2443
mapInput: ({ params, body }) => ({
2544
workspaceId: body.workspaceId,
2645
toolId: params.toolId,

‎apps/sim/lib/api/contracts/v2/openapi/resources.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2100,8 +2100,8 @@ const declaredRoutes = [
21002100
applicationOperation: toolExecutionOperations.execute,
21012101
operationId: 'executeTool',
21022102
summary: 'Run Tool',
2103-
description: `Run a built-in tool using published parameter IDs. Sim resolves \`credentialId\`, hosted keys, and whole-value \`{{VAR_NAME}}\` references for \`user-only\` parameters; other values pass through verbatim. Third-party refusal returns \`200\` with \`status: "failed"\`; the error envelope covers API failures. Hidden or missing tools return \`404\`; disallowed integrations return \`403\` with \`error.details.code: INTEGRATION_NOT_ALLOWED\`. Hosted-key use is billed to the workspace. ${WORKSPACE_API_KEY_DENIED}`,
2104-
errors: RESOURCE_ERRORS,
2103+
description: `Run a built-in tool using published parameter IDs. Sim resolves \`credentialId\`, hosted keys, and whole-value \`{{VAR_NAME}}\` references for \`user-only\` parameters; other values pass through verbatim. Third-party refusal returns \`200\` with \`status: "failed"\`; the error envelope covers API failures. Hidden or missing tools return \`404\`; disallowed integrations return \`403\` with \`error.details.code: INTEGRATION_NOT_ALLOWED\`. Hosted-key use is billed to the workspace; a call that would use a hosted key from a workspace over its usage or billing limits returns \`402\`. ${WORKSPACE_API_KEY_DENIED}`,
2104+
errors: [...RESOURCE_ERRORS, 'UsageLimitExceeded'],
21052105
success: { description: 'The outcome of the tool call.' },
21062106
}),
21072107
{

‎apps/sim/lib/api/mcp/generated/v2-operations.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1162,7 +1162,7 @@ export const V2_MCP_OPERATIONS = {
11621162
contract: v2ExecuteToolContract,
11631163
summary: 'Run Tool',
11641164
description:
1165-
'Run a built-in tool using published parameter IDs. Sim resolves `credentialId`, hosted keys, and whole-value `{{VAR_NAME}}` references for `user-only` parameters; other values pass through verbatim. Third-party refusal returns `200` with `status: "failed"`; the error envelope covers API failures. Hidden or missing tools return `404`; disallowed integrations return `403` with `error.details.code: INTEGRATION_NOT_ALLOWED`. Hosted-key use is billed to the workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.',
1165+
'Run a built-in tool using published parameter IDs. Sim resolves `credentialId`, hosted keys, and whole-value `{{VAR_NAME}}` references for `user-only` parameters; other values pass through verbatim. Third-party refusal returns `200` with `status: "failed"`; the error envelope covers API failures. Hidden or missing tools return `404`; disallowed integrations return `403` with `error.details.code: INTEGRATION_NOT_ALLOWED`. Hosted-key use is billed to the workspace; a call that would use a hosted key from a workspace over its usage or billing limits returns `402`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.',
11661166
workspaceKeyUnsupported: true,
11671167
handler: () => import('@/app/api/v2/tools/[toolId]/execute/route').then((route) => route.POST),
11681168
},

‎apps/sim/lib/tool-execution/application/execute-tool.test.ts‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
billingAttributionMock,
1010
billingAttributionMockFns,
1111
} from '@sim/testing/mocks/billing-attribution.mock'
12+
import {
13+
billingUsageGateCacheMock,
14+
billingUsageGateCacheMockFns,
15+
} from '@sim/testing/mocks/billing-usage-gate-cache.mock'
1216
import {
1317
billingUsageLogMock,
1418
billingUsageLogMockFns,
@@ -22,6 +26,7 @@ import {
2226
customBlockOperationsMockFns,
2327
} from '@sim/testing/mocks/custom-block-operations.mock'
2428
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
29+
import { environmentUtilsMockFns } from '@sim/testing/mocks/environment-utils.mock'
2530
import {
2631
integrationsAvailabilityMock,
2732
integrationsAvailabilityMockFns,
@@ -87,11 +92,16 @@ vi.mock('@/lib/internal/file/operations', () => ({
8792

8893
vi.mock('@/lib/billing/core/billing-attribution', () => billingAttributionMock)
8994

95+
vi.mock('@/lib/billing/core/usage-gate-cache', () => billingUsageGateCacheMock)
96+
9097
vi.mock('@/lib/billing/core/usage-log', () => billingUsageLogMock)
9198

9299
import { executeFileTool } from '@/lib/internal/file/execute-tool'
93100
import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types'
94-
import { executeToolForCaller } from '@/lib/tool-execution/application/execute-tool'
101+
import {
102+
executeToolForCaller,
103+
ToolUsageLimitExceededError,
104+
} from '@/lib/tool-execution/application/execute-tool'
95105
import { getAllBlocks, getBlock, getBlockMeta } from '@/blocks/registry'
96106
import type { BlockConfig } from '@/blocks/types'
97107
import { fileReadTool } from '@/tools/file/get'
@@ -116,6 +126,18 @@ const TOOL_METADATA: Record<string, Record<string, unknown>> = {
116126
},
117127
hosting: { apiKeyParam: 'apiKey' },
118128
},
129+
image_generate: {
130+
id: 'image_generate',
131+
name: 'Image Generate',
132+
params: {
133+
provider: { type: 'string', required: true, visibility: 'user-only' },
134+
apiKey: { type: 'string', required: true, visibility: 'user-only' },
135+
},
136+
hosting: {
137+
apiKeyParam: 'apiKey',
138+
enabled: (params: { provider?: unknown }) => params.provider === 'falai',
139+
},
140+
},
119141
snowflake_execute_sql: {
120142
id: 'snowflake_execute_sql',
121143
name: 'Snowflake Execute SQL',
@@ -156,6 +178,7 @@ const mocks = {
156178
getAllBlocks: vi.mocked(getAllBlocks),
157179
executeRegistryTool: toolsMockFns.mockExecuteTool,
158180
resolveBillingAttribution: billingAttributionMockFns.mockResolveBillingAttribution,
181+
checkUsageLimits: billingUsageGateCacheMockFns.mockCheckExecutionUsageLimits,
159182
}
160183

161184
vi.mocked(getBlock).mockReturnValue(undefined as never)
@@ -193,6 +216,7 @@ function block(overrides: Partial<BlockConfig> & { type: string }): BlockConfig
193216
const fileBlock = block({ type: 'file_v5', tools: { access: ['file_read'] } })
194217
const slackBlock = block({ type: 'slack', tools: { access: ['slack_message'] } })
195218
const firecrawlBlock = block({ type: 'firecrawl', tools: { access: ['firecrawl_scrape'] } })
219+
const imageBlock = block({ type: 'image_generator', tools: { access: ['image_generate'] } })
196220
const previewBlock = block({
197221
type: 'preview_thing',
198222
preview: true,
@@ -234,6 +258,7 @@ describe('executeToolForCaller', () => {
234258
fileBlock,
235259
slackBlock,
236260
firecrawlBlock,
261+
imageBlock,
237262
previewBlock,
238263
confluenceBlock,
239264
zendeskBlock,
@@ -242,6 +267,8 @@ describe('executeToolForCaller', () => {
242267
])
243268
mocks.executeRegistryTool.mockResolvedValue({ success: true, output: { markdown: '# Hi' } })
244269
mocks.resolveBillingAttribution.mockResolvedValue({ workspaceId: WORKSPACE_ID })
270+
mocks.checkUsageLimits.mockResolvedValue({ isExceeded: false })
271+
environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue({})
245272
})
246273

247274
it.each<PersonalApiKeyPrincipal | SessionPrincipal>([principal, createSessionPrincipal()])(
@@ -498,6 +525,59 @@ describe('executeToolForCaller', () => {
498525
expect(mocks.recordUsage).not.toHaveBeenCalled()
499526
})
500527

528+
const referencedImageCall = {
529+
toolId: 'image_generate',
530+
input: { provider: '{{IMAGE_PROVIDER}}', apiKey: '{{IMAGE_KEY}}' },
531+
}
532+
533+
it.each<[string, Parameters<typeof run>[0], Record<string, string>]>([
534+
['the key is omitted', { input: { url: 'https://a.co' } }, {}],
535+
[
536+
'the key references an empty variable',
537+
{ input: { url: 'https://a.co', apiKey: '{{FIRECRAWL_KEY}}' } },
538+
{ FIRECRAWL_KEY: ' ' },
539+
],
540+
[
541+
'a reference selects the hosted provider',
542+
referencedImageCall,
543+
{ IMAGE_PROVIDER: 'falai', IMAGE_KEY: '' },
544+
],
545+
])('refuses a hosted-key call over the usage limit when %s', async (_case, input, env) => {
546+
mocks.checkUsageLimits.mockResolvedValue({ isExceeded: true, message: 'Usage limit exceeded' })
547+
environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue(env)
548+
549+
await expect(run(input)).rejects.toBeInstanceOf(ToolUsageLimitExceededError)
550+
})
551+
552+
it.each<[string, Parameters<typeof run>[0], Record<string, string>]>([
553+
['the caller brings their own key', { input: { url: 'https://a.co', apiKey: 'sk-own' } }, {}],
554+
[
555+
'the caller references a variable holding their own key',
556+
{ input: { url: 'https://a.co', apiKey: '{{FIRECRAWL_KEY}}' } },
557+
{ FIRECRAWL_KEY: 'fc-own' },
558+
],
559+
[
560+
'the reference pads the variable name',
561+
{ input: { url: 'https://a.co', apiKey: '{{ FIRECRAWL_KEY }}' } },
562+
{ FIRECRAWL_KEY: 'fc-own' },
563+
],
564+
[
565+
'a reference selects a provider Sim does not host',
566+
referencedImageCall,
567+
{ IMAGE_PROVIDER: 'openai', IMAGE_KEY: '' },
568+
],
569+
[
570+
'the tool has no hosted key',
571+
{ toolId: 'zendesk_get_ticket', input: { ticketId: '4', subdomain: 'a', apiToken: 't' } },
572+
{},
573+
],
574+
])('does not gate on usage when %s', async (_case, input, env) => {
575+
mocks.checkUsageLimits.mockResolvedValue({ isExceeded: true, message: 'Usage limit exceeded' })
576+
environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue(env)
577+
578+
await expect(run(input)).resolves.toMatchObject({ status: 'succeeded' })
579+
})
580+
501581
/**
502582
* The provider already ran and already charged Sim's key, so losing the
503583
* ledger row must not also lose the caller's result.

‎apps/sim/lib/tool-execution/application/execute-tool.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { generateId } from '@sim/utils/id'
44
import { resolveBillingAttribution, toBillingContext } from '@/lib/billing/core/billing-attribution'
5+
import { checkExecutionUsageLimits } from '@/lib/billing/core/usage-gate-cache'
56
import { recordUsage } from '@/lib/billing/core/usage-log'
67
import {
78
isBlockTypeAllowed,
@@ -16,8 +17,11 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
1617
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
1718
import { isHosted } from '@/lib/core/config/env-flags'
1819
import { OrchestrationError } from '@/lib/core/orchestration/types'
20+
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
1921
import { principalUserId } from '@/lib/integrations/principal-scope.server'
2022
import { toolExecutionOperations } from '@/lib/tool-execution/application/operations'
23+
import { isEnvVarReference } from '@/executor/constants'
24+
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
2125
import { executeTool as executeRegistryTool } from '@/tools'
2226
import type { ExecutableToolConfig } from '@/tools/types'
2327
import { getTool } from '@/tools/utils'
@@ -34,6 +38,13 @@ export interface ExecuteToolInput {
3438
timeoutSeconds?: number
3539
}
3640

41+
export class ToolUsageLimitExceededError extends Error {
42+
constructor(message: string) {
43+
super(message)
44+
this.name = 'ToolUsageLimitExceededError'
45+
}
46+
}
47+
3748
export interface ExecuteToolResult {
3849
toolId: string
3950
status: 'succeeded' | 'failed'
@@ -49,10 +60,10 @@ export interface ExecuteToolResult {
4960
* deployment hosts keys, any `enabled` predicate accepts these params, and the
5061
* caller has not brought a key of their own — which wins where present.
5162
*
52-
* Pre-dispatch only, for the required-input exemption: a parameter Sim will
53-
* fill is not missing. It is deliberately NOT the metering gate — it cannot see
54-
* a BYOK key, which the registry injects while reporting the call as *not*
55-
* hosted, so after dispatch the registry's own verdict is read instead.
63+
* Pre-dispatch only: the required-input exemption (a parameter Sim will fill is
64+
* not missing) and usage admission. It is deliberately NOT the metering gate —
65+
* it cannot see a BYOK key, which the registry injects while reporting the call
66+
* as *not* hosted, so after dispatch the registry's own verdict is read instead.
5667
*/
5768
function hostedKeyParamFor(
5869
tool: ExecutableToolConfig,
@@ -65,6 +76,42 @@ function hostedKeyParamFor(
6576
return tool.hosting.apiKeyParam
6677
}
6778

79+
/**
80+
* `params` as the registry holds them when it decides on Sim's key.
81+
*
82+
* The registry resolves each whole-value `{{VAR}}` in a `user-only` parameter
83+
* before `injectHostedKeyIfNeeded` runs, so a reference can supply the key, or
84+
* the value an `enabled` predicate reads, and an empty variable leaves the key
85+
* for Sim's to fill. Resolved the same way here — same environment, same
86+
* options. A missing variable stays as written: the registry refuses the call
87+
* on it before any key is spent.
88+
*/
89+
async function resolveUserOnlyReferences(
90+
tool: ExecutableToolConfig,
91+
params: Record<string, unknown>,
92+
userId: string,
93+
workspaceId: string
94+
): Promise<Record<string, unknown>> {
95+
const referenced = Object.entries(tool.params ?? {})
96+
.filter(([name, declaration]) => {
97+
const value = params[name]
98+
return (
99+
declaration?.visibility === 'user-only' &&
100+
typeof value === 'string' &&
101+
isEnvVarReference(value)
102+
)
103+
})
104+
.map(([name]) => name)
105+
if (referenced.length === 0) return params
106+
107+
const env = await getEffectiveDecryptedEnv(userId, workspaceId)
108+
const resolved = { ...params }
109+
for (const name of referenced) {
110+
resolved[name] = resolveEnvVarReferences(params[name], env, { allowEmbedded: false })
111+
}
112+
return resolved
113+
}
114+
68115
/**
69116
* The three spellings the executor accepts for "which credential".
70117
*
@@ -302,6 +349,25 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
302349
workspaceId: context.workspaceId,
303350
})
304351

352+
/**
353+
* Admission before Sim's key is spent: metering runs only after the provider
354+
* has charged it. A BYOK workspace is gated too, since only the registry can
355+
* see that key — the same standing every workflow run is held to.
356+
*/
357+
if (
358+
hostedKeyParamFor(
359+
tool,
360+
await resolveUserOnlyReferences(tool, callerParams, userId, context.workspaceId)
361+
)
362+
) {
363+
const usage = await checkExecutionUsageLimits(billingAttribution)
364+
if (usage.isExceeded) {
365+
throw new ToolUsageLimitExceededError(
366+
usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.'
367+
)
368+
}
369+
}
370+
305371
const params: Record<string, unknown> = {
306372
...callerParams,
307373
_context: {

0 commit comments

Comments
 (0)