fix(chat): hosted key execution via call-integration-tool (#5745)

* fix(chat): hosted key execution via call-integration-tool

* fix optional hosted key
This commit is contained in:
Vikhyath Mondreti
2026-07-17 14:04:35 -07:00
committed by GitHub
parent ec75084dbf
commit b2d9c02bb9
4 changed files with 159 additions and 3 deletions
+2 -2
View File
@@ -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) }
)
})
+4
View File
@@ -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({
+128
View File
@@ -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<string, unknown>) => 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,
+25 -1
View File
@@ -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)
}
}