Skip to content

Commit b2d9c02

Browse files
fix(chat): hosted key execution via call-integration-tool (#5745)
* fix(chat): hosted key execution via call-integration-tool * fix optional hosted key
1 parent ec75084 commit b2d9c02

4 files changed

Lines changed: 159 additions & 3 deletions

File tree

apps/sim/lib/copilot/chat/payload.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,11 @@ describe('buildIntegrationToolSchemas', () => {
185185

186186
expect(mockCreateUserToolSchema).toHaveBeenCalledWith(
187187
expect.objectContaining({ id: 'gmail_send' }),
188-
{ surface: 'copilot' }
188+
{ surface: 'copilot', hostedKeySupport: expect.any(Boolean) }
189189
)
190190
expect(mockCreateUserToolSchema).toHaveBeenCalledWith(
191191
expect.objectContaining({ id: 'brandfetch_search' }),
192-
{ surface: 'copilot' }
192+
{ surface: 'copilot', hostedKeySupport: expect.any(Boolean) }
193193
)
194194
})
195195

apps/sim/lib/copilot/chat/payload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ async function buildIntegrationToolSchemasUncached(
226226
}
227227
const userSchema = createUserToolSchema(toolConfig, {
228228
surface: options.schemaSurface,
229+
// On hosted deployments the executor injects hosted keys server-side,
230+
// so the gateway schema must not force the model to supply one (the
231+
// model never sees the key either way).
232+
hostedKeySupport: isHosted,
229233
})
230234
const catalogEntry = getToolEntry(toolId)
231235
integrationTools.push({

apps/sim/tools/params.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,134 @@ describe('Tool Parameters Utils', () => {
142142
expect(schema.properties).toHaveProperty('message')
143143
})
144144

145+
it.concurrent('keeps the hosted key param optional when hosted keys are supported', () => {
146+
const hostedTool = {
147+
...mockToolConfig,
148+
id: 'hosted_key_tool',
149+
params: {
150+
query: {
151+
type: 'string',
152+
required: true,
153+
visibility: 'user-or-llm' as ParameterVisibility,
154+
description: 'Search query',
155+
},
156+
apiKey: {
157+
type: 'string',
158+
required: true,
159+
visibility: 'user-only' as ParameterVisibility,
160+
description: 'Exa AI API Key',
161+
},
162+
},
163+
hosting: {
164+
envKeyPrefix: 'EXA_API_KEY',
165+
apiKeyParam: 'apiKey',
166+
byokProviderId: 'exa',
167+
pricing: { type: 'per_request' as const, cost: 0.005 },
168+
rateLimit: { mode: 'per_request' as const, requestsPerMinute: 100 },
169+
},
170+
}
171+
172+
const hostedSchema = createUserToolSchema(hostedTool, {
173+
surface: 'copilot',
174+
hostedKeySupport: true,
175+
})
176+
177+
// The key stays available as a bring-your-own-key override but is never
178+
// a required argument — the executor injects the hosted key server-side.
179+
expect(hostedSchema.properties).toHaveProperty('apiKey')
180+
expect(hostedSchema.required).not.toContain('apiKey')
181+
expect(hostedSchema.properties.apiKey.description).toContain('hosted key')
182+
expect(hostedSchema.required).toContain('query')
183+
})
184+
185+
it.concurrent('keeps the hosted key param required without hosted key support', () => {
186+
const hostedTool = {
187+
...mockToolConfig,
188+
id: 'hosted_key_tool_self_hosted',
189+
params: {
190+
apiKey: {
191+
type: 'string',
192+
required: true,
193+
visibility: 'user-only' as ParameterVisibility,
194+
description: 'Exa AI API Key',
195+
},
196+
},
197+
hosting: {
198+
envKeyPrefix: 'EXA_API_KEY',
199+
apiKeyParam: 'apiKey',
200+
byokProviderId: 'exa',
201+
pricing: { type: 'per_request' as const, cost: 0.005 },
202+
rateLimit: { mode: 'per_request' as const, requestsPerMinute: 100 },
203+
},
204+
}
205+
206+
const selfHostedSchema = createUserToolSchema(hostedTool, { surface: 'copilot' })
207+
208+
expect(selfHostedSchema.required).toContain('apiKey')
209+
expect(selfHostedSchema.properties.apiKey.description).not.toContain('hosted key')
210+
})
211+
212+
it.concurrent('keeps the key required for conditionally hosted tools', () => {
213+
const enabled = Object.assign(
214+
(params: Record<string, unknown>) => params.provider === 'falai',
215+
{
216+
condition: { field: 'provider', operator: 'equals' as const, value: 'falai' },
217+
}
218+
)
219+
const conditionalTool = {
220+
...mockToolConfig,
221+
id: 'conditional_hosted_tool',
222+
params: {
223+
apiKey: {
224+
type: 'string',
225+
required: true,
226+
visibility: 'user-only' as ParameterVisibility,
227+
description: 'Provider API Key',
228+
},
229+
},
230+
hosting: {
231+
enabled,
232+
envKeyPrefix: 'FALAI_API_KEY',
233+
apiKeyParam: 'apiKey',
234+
byokProviderId: 'falai',
235+
pricing: { type: 'per_request' as const, cost: 0.01 },
236+
rateLimit: { mode: 'per_request' as const, requestsPerMinute: 100 },
237+
},
238+
}
239+
240+
const schema = createUserToolSchema(conditionalTool, {
241+
surface: 'copilot',
242+
hostedKeySupport: true,
243+
})
244+
245+
// Injection only happens when the predicate passes at runtime, so the
246+
// schema must not promise a hosted key for every configuration.
247+
expect(schema.required).toContain('apiKey')
248+
expect(schema.properties.apiKey.description).not.toContain('hosted key')
249+
})
250+
251+
it.concurrent('does not relax required keys on tools without hosting', () => {
252+
const plainTool = {
253+
...mockToolConfig,
254+
id: 'plain_key_tool',
255+
params: {
256+
apiKey: {
257+
type: 'string',
258+
required: true,
259+
visibility: 'user-only' as ParameterVisibility,
260+
description: 'Service API Key',
261+
},
262+
},
263+
}
264+
265+
const schema = createUserToolSchema(plainTool, {
266+
surface: 'copilot',
267+
hostedKeySupport: true,
268+
})
269+
270+
expect(schema.required).toContain('apiKey')
271+
})
272+
145273
it.concurrent('adds credentialId only for copilot-facing oauth schemas', () => {
146274
const oauthTool = {
147275
...mockToolConfig,

apps/sim/tools/params.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ export interface ToolSchema {
129129

130130
export interface UserToolSchemaOptions {
131131
surface?: 'default' | 'copilot'
132+
/**
133+
* Set when the deployment provides hosted API keys for tools with a
134+
* `hosting` config. For unconditionally hosted tools the key param then
135+
* stays in the schema only as an optional bring-your-own-key override
136+
* instead of a required argument — the executor injects the hosted key
137+
* server-side after validation, and the key value itself is never exposed
138+
* to the model or the mothership. Tools with a conditional
139+
* `hosting.enabled` predicate keep the key required, since injection only
140+
* happens for configurations that satisfy the predicate (mirrors the VFS
141+
* `conditional_hosted_or_byok` auth mode).
142+
*/
143+
hostedKeySupport?: boolean
132144
}
133145

134146
export interface LLMToolSchemaResult {
@@ -505,6 +517,10 @@ export function createUserToolSchema(
505517
options: UserToolSchemaOptions = {}
506518
): ToolSchema {
507519
const surface = options.surface ?? 'default'
520+
const hostedApiKeyParam =
521+
options.hostedKeySupport && toolConfig.hosting && !toolConfig.hosting.enabled
522+
? toolConfig.hosting.apiKeyParam
523+
: undefined
508524
const schema: ToolSchema = {
509525
type: 'object',
510526
properties: {},
@@ -519,9 +535,17 @@ export function createUserToolSchema(
519535
}
520536

521537
const propertySchema = buildParameterSchema(toolConfig.id, paramId, param, options)
538+
if (paramId === hostedApiKeyParam) {
539+
propertySchema.description = [
540+
propertySchema.description,
541+
'Optional: Sim provides a hosted key for this tool. Omit this parameter unless intentionally overriding with your own key.',
542+
]
543+
.filter(Boolean)
544+
.join(' ')
545+
}
522546
schema.properties[paramId] = propertySchema
523547

524-
if (param.required) {
548+
if (param.required && paramId !== hostedApiKeyParam) {
525549
schema.required.push(paramId)
526550
}
527551
}

0 commit comments

Comments
 (0)