diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index f7a68910a00..85aec553ba3 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -185,11 +185,11 @@ describe('buildIntegrationToolSchemas', () => { expect(mockCreateUserToolSchema).toHaveBeenCalledWith( expect.objectContaining({ id: 'gmail_send' }), - { surface: 'copilot' } + { surface: 'copilot', hostedKeySupport: expect.any(Boolean) } ) expect(mockCreateUserToolSchema).toHaveBeenCalledWith( expect.objectContaining({ id: 'brandfetch_search' }), - { surface: 'copilot' } + { surface: 'copilot', hostedKeySupport: expect.any(Boolean) } ) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index b4f56f4b014..5afe545ab87 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -226,6 +226,10 @@ async function buildIntegrationToolSchemasUncached( } const userSchema = createUserToolSchema(toolConfig, { surface: options.schemaSurface, + // On hosted deployments the executor injects hosted keys server-side, + // so the gateway schema must not force the model to supply one (the + // model never sees the key either way). + hostedKeySupport: isHosted, }) const catalogEntry = getToolEntry(toolId) integrationTools.push({ diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index 619323ce172..5afa84eaf54 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -142,6 +142,134 @@ describe('Tool Parameters Utils', () => { expect(schema.properties).toHaveProperty('message') }) + it.concurrent('keeps the hosted key param optional when hosted keys are supported', () => { + const hostedTool = { + ...mockToolConfig, + id: 'hosted_key_tool', + params: { + query: { + type: 'string', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Search query', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only' as ParameterVisibility, + description: 'Exa AI API Key', + }, + }, + hosting: { + envKeyPrefix: 'EXA_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'exa', + pricing: { type: 'per_request' as const, cost: 0.005 }, + rateLimit: { mode: 'per_request' as const, requestsPerMinute: 100 }, + }, + } + + const hostedSchema = createUserToolSchema(hostedTool, { + surface: 'copilot', + hostedKeySupport: true, + }) + + // The key stays available as a bring-your-own-key override but is never + // a required argument — the executor injects the hosted key server-side. + expect(hostedSchema.properties).toHaveProperty('apiKey') + expect(hostedSchema.required).not.toContain('apiKey') + expect(hostedSchema.properties.apiKey.description).toContain('hosted key') + expect(hostedSchema.required).toContain('query') + }) + + it.concurrent('keeps the hosted key param required without hosted key support', () => { + const hostedTool = { + ...mockToolConfig, + id: 'hosted_key_tool_self_hosted', + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only' as ParameterVisibility, + description: 'Exa AI API Key', + }, + }, + hosting: { + envKeyPrefix: 'EXA_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'exa', + pricing: { type: 'per_request' as const, cost: 0.005 }, + rateLimit: { mode: 'per_request' as const, requestsPerMinute: 100 }, + }, + } + + const selfHostedSchema = createUserToolSchema(hostedTool, { surface: 'copilot' }) + + expect(selfHostedSchema.required).toContain('apiKey') + expect(selfHostedSchema.properties.apiKey.description).not.toContain('hosted key') + }) + + it.concurrent('keeps the key required for conditionally hosted tools', () => { + const enabled = Object.assign( + (params: Record) => params.provider === 'falai', + { + condition: { field: 'provider', operator: 'equals' as const, value: 'falai' }, + } + ) + const conditionalTool = { + ...mockToolConfig, + id: 'conditional_hosted_tool', + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only' as ParameterVisibility, + description: 'Provider API Key', + }, + }, + hosting: { + enabled, + envKeyPrefix: 'FALAI_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'falai', + pricing: { type: 'per_request' as const, cost: 0.01 }, + rateLimit: { mode: 'per_request' as const, requestsPerMinute: 100 }, + }, + } + + const schema = createUserToolSchema(conditionalTool, { + surface: 'copilot', + hostedKeySupport: true, + }) + + // Injection only happens when the predicate passes at runtime, so the + // schema must not promise a hosted key for every configuration. + expect(schema.required).toContain('apiKey') + expect(schema.properties.apiKey.description).not.toContain('hosted key') + }) + + it.concurrent('does not relax required keys on tools without hosting', () => { + const plainTool = { + ...mockToolConfig, + id: 'plain_key_tool', + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only' as ParameterVisibility, + description: 'Service API Key', + }, + }, + } + + const schema = createUserToolSchema(plainTool, { + surface: 'copilot', + hostedKeySupport: true, + }) + + expect(schema.required).toContain('apiKey') + }) + it.concurrent('adds credentialId only for copilot-facing oauth schemas', () => { const oauthTool = { ...mockToolConfig, diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index a506eb0bc7e..26e6d67abfa 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -129,6 +129,18 @@ export interface ToolSchema { export interface UserToolSchemaOptions { surface?: 'default' | 'copilot' + /** + * Set when the deployment provides hosted API keys for tools with a + * `hosting` config. For unconditionally hosted tools the key param then + * stays in the schema only as an optional bring-your-own-key override + * instead of a required argument — the executor injects the hosted key + * server-side after validation, and the key value itself is never exposed + * to the model or the mothership. Tools with a conditional + * `hosting.enabled` predicate keep the key required, since injection only + * happens for configurations that satisfy the predicate (mirrors the VFS + * `conditional_hosted_or_byok` auth mode). + */ + hostedKeySupport?: boolean } export interface LLMToolSchemaResult { @@ -505,6 +517,10 @@ export function createUserToolSchema( options: UserToolSchemaOptions = {} ): ToolSchema { const surface = options.surface ?? 'default' + const hostedApiKeyParam = + options.hostedKeySupport && toolConfig.hosting && !toolConfig.hosting.enabled + ? toolConfig.hosting.apiKeyParam + : undefined const schema: ToolSchema = { type: 'object', properties: {}, @@ -519,9 +535,17 @@ export function createUserToolSchema( } const propertySchema = buildParameterSchema(toolConfig.id, paramId, param, options) + if (paramId === hostedApiKeyParam) { + propertySchema.description = [ + propertySchema.description, + 'Optional: Sim provides a hosted key for this tool. Omit this parameter unless intentionally overriding with your own key.', + ] + .filter(Boolean) + .join(' ') + } schema.properties[paramId] = propertySchema - if (param.required) { + if (param.required && paramId !== hostedApiKeyParam) { schema.required.push(paramId) } }