mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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) }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user