Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions src/server/llm/client-pure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,12 +377,38 @@ describe('llm client pure helpers', () => {
expect(withoutFlag).toEqual([{ role: 'user', content: 'hi' }])
})

it('converts tool definitions to openai function schema', () => {
it('converts tool definitions to openai function schema and sanitizes invalid schema fields', () => {
expect(
convertTools([
{ type: 'function', function: { name: 'grep', description: 'Search', parameters: { type: 'object' } } },
{
type: 'function',
function: {
name: 'grep',
description: 'Search',
parameters: {
type: 'object',
properties: {
tags: { type: 'array', items: {} },
},
},
},
},
]),
).toEqual([{ type: 'function', function: { name: 'grep', description: 'Search', parameters: { type: 'object' } } }])
).toEqual([
{
type: 'function',
function: {
name: 'grep',
description: 'Search',
parameters: {
type: 'object',
properties: {
tags: { type: 'array', items: { type: 'string' } },
},
},
},
},
])
})

it('maps finish reasons', () => {
Expand Down Expand Up @@ -588,7 +614,10 @@ describe('llm client pure helpers', () => {
model: 'test-model',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{ type: 'function', function: { name: 'glob', description: 'Search', parameters: { type: 'object' } } },
{
type: 'function',
function: { name: 'glob', description: 'Search', parameters: { type: 'object', properties: {} } },
},
],
tool_choice: 'auto',
temperature: 0.2,
Expand Down Expand Up @@ -629,7 +658,10 @@ describe('llm client pure helpers', () => {
model: 'test-model',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{ type: 'function', function: { name: 'glob', description: 'Search', parameters: { type: 'object' } } },
{
type: 'function',
function: { name: 'glob', description: 'Search', parameters: { type: 'object', properties: {} } },
},
],
tool_choice: 'auto',
temperature: 0.2,
Expand Down Expand Up @@ -670,7 +702,10 @@ describe('llm client pure helpers', () => {
model: 'test-model',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{ type: 'function', function: { name: 'glob', description: 'Search', parameters: { type: 'object' } } },
{
type: 'function',
function: { name: 'glob', description: 'Search', parameters: { type: 'object', properties: {} } },
},
],
tool_choice: 'auto',
temperature: 0.2,
Expand Down Expand Up @@ -711,7 +746,10 @@ describe('llm client pure helpers', () => {
model: 'test-model',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{ type: 'function', function: { name: 'glob', description: 'Search', parameters: { type: 'object' } } },
{
type: 'function',
function: { name: 'glob', description: 'Search', parameters: { type: 'object', properties: {} } },
},
],
tool_choice: 'auto',
temperature: 0.2,
Expand Down Expand Up @@ -753,7 +791,10 @@ describe('llm client pure helpers', () => {
model: 'test-model',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{ type: 'function', function: { name: 'glob', description: 'Search', parameters: { type: 'object' } } },
{
type: 'function',
function: { name: 'glob', description: 'Search', parameters: { type: 'object', properties: {} } },
},
],
tool_choice: 'auto',
temperature: 0.2,
Expand Down
3 changes: 2 additions & 1 deletion src/server/llm/client-pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
extractPdfBlocksFromDataUrl,
formatVisionFallbackDescription,
} from './resolve-attachments.js'
import { sanitizeToolSchema } from './schema-sanitizer.js'

import type { ContentPart } from './resolve-attachments.js'
export { resolveAttachmentsInMessages } from './resolve-attachments.js'
Expand Down Expand Up @@ -255,7 +256,7 @@ export function convertTools(tools: LLMToolDefinition[]): ChatCompletionTool[] {
function: {
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
parameters: sanitizeToolSchema(tool.function.parameters),
},
}))
}
Expand Down
199 changes: 199 additions & 0 deletions src/server/llm/schema-sanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { describe, it, expect } from 'vitest'
import { sanitizeToolSchema } from './schema-sanitizer.js'

describe('sanitizeToolSchema', () => {
it('returns valid default schema for non-object inputs', () => {
expect(sanitizeToolSchema(null)).toEqual({ type: 'object', properties: {} })
expect(sanitizeToolSchema(undefined)).toEqual({ type: 'object', properties: {} })
expect(sanitizeToolSchema('string')).toEqual({ type: 'object', properties: {} })
expect(sanitizeToolSchema([])).toEqual({ type: 'object', properties: {} })
})

it('strips unsupported JSON schema keywords and additionalProperties', () => {
const input = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: 'http://example.com/schema.json',
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', patternProperties: {} },
},
}
expect(sanitizeToolSchema(input)).toEqual({
type: 'object',
properties: {
name: { type: 'string' },
},
})
})

it('strips null and undefined values such as default: null', () => {
const input = {
type: 'object',
properties: {
expand: { type: 'string', default: null },
include: { type: 'string', default: null },
count: { type: 'number', default: 10 },
},
}
expect(sanitizeToolSchema(input)).toEqual({
type: 'object',
properties: {
expand: { type: 'string' },
include: { type: 'string' },
count: { type: 'number', default: 10 },
},
})
})

it('converts const to enum', () => {
const input = {
type: 'object',
properties: {
mode: { const: 'exact' },
},
}
expect(sanitizeToolSchema(input)).toEqual({
type: 'object',
properties: {
mode: { enum: ['exact'] },
},
})
})

it('normalizes empty or missing array items to { type: "string" }', () => {
const input = {
type: 'object',
properties: {
tags: { type: 'array', items: {} },
labels: { type: 'array' },
},
}
expect(sanitizeToolSchema(input)).toEqual({
type: 'object',
properties: {
tags: { type: 'array', items: { type: 'string' } },
labels: { type: 'array', items: { type: 'string' } },
},
})
})

it('ensures type: object always has a properties map', () => {
const input = {
type: 'object',
properties: {
env: { type: 'object' },
},
}
expect(sanitizeToolSchema(input)).toEqual({
type: 'object',
properties: {
env: { type: 'object', properties: {} },
},
})
})

it('normalizes string property values in properties map', () => {
const input = {
type: 'object',
properties: {
env: 'object',
name: 'string',
},
}
expect(sanitizeToolSchema(input)).toEqual({
type: 'object',
properties: {
env: { type: 'object', properties: {} },
name: { type: 'string' },
},
})
})

it('sanitizes real Jira MCP tool schema containing additionalProperties and null defaults', () => {
const jiraSchema = {
type: 'object',
properties: {
issue_key: {
description: "Jira issue key (e.g., 'PROJ-123')",
type: 'string',
},
expand: {
default: null,
description: '(Optional) Fields to expand',
type: 'string',
},
properties: {
description: '(Optional) A comma-separated list of issue properties to return',
default: null,
type: 'string',
},
comment_limit: {
default: 10,
maximum: 100,
minimum: 0,
type: 'integer',
},
},
required: ['issue_key'],
additionalProperties: false,
}

const sanitized = sanitizeToolSchema(jiraSchema)

expect(sanitized).toEqual({
type: 'object',
properties: {
issue_key: {
description: "Jira issue key (e.g., 'PROJ-123')",
type: 'string',
},
expand: {
description: '(Optional) Fields to expand',
type: 'string',
},
props: {
description: '(Optional) A comma-separated list of issue properties to return',
type: 'string',
},
comment_limit: {
default: 10,
maximum: 100,
minimum: 0,
type: 'integer',
},
},
required: ['issue_key'],
})
expect(sanitized).not.toHaveProperty('additionalProperties')
})

it('recursively sanitizes nested properties and oneOf/anyOf branches', () => {
const input = {
type: 'object',
properties: {
config: {
type: 'object',
properties: {
subOptions: {
oneOf: [{ const: 'auto' }, { type: 'array', items: {} }],
},
},
},
},
}
expect(sanitizeToolSchema(input)).toEqual({
type: 'object',
properties: {
config: {
type: 'object',
properties: {
subOptions: {
oneOf: [{ enum: ['auto'] }, { type: 'array', items: { type: 'string' } }],
},
},
},
},
})
})
})
Loading
Loading