fix(stage-ui): normalize tool schemas per provider (#2330)

This commit is contained in:
Neko
2026-08-20 21:40:13 +08:00
committed by GitHub
parent 00240e2535
commit a24d2496e4
10 changed files with 442 additions and 203 deletions
@@ -0,0 +1,89 @@
import type { JsonSchema } from 'xsschema'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSparkCommandTool } from '../../../../tools/character/orchestrator/spark-command'
import { providerAzureOpenAI } from './index'
interface ChatRequestBody {
tools: Array<{
function: {
name: string
parameters: JsonSchema
}
}>
}
function isJsonSchema(value: JsonSchema | boolean | undefined): value is JsonSchema {
return Boolean(value && typeof value === 'object')
}
function getArraySchema(schema?: JsonSchema): JsonSchema | undefined {
if (!schema)
return undefined
if (schema.type === 'array')
return schema
return schema.anyOf?.filter(isJsonSchema).find(candidate => candidate.type === 'array')
}
describe('providerAzureOpenAI tool schemas', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
// https://github.com/moeru-ai/airi/pull/2330#discussion_r3819919459
it('converts every nullable scalar anyOf before it sends a chat request (PR #2330 review)', async () => {
const tools = await createSparkCommandTool({
sendSparkCommand: () => undefined,
})
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response('{}'))
vi.stubGlobal('fetch', fetchMock)
const provider = providerAzureOpenAI.createProvider({
apiKey: 'test-key',
baseUrl: 'https://example.openai.azure.com/openai/',
})
if (!('chat' in provider))
throw new Error('Azure OpenAI did not create a chat provider.')
const providerFetch = provider.chat('test-deployment').fetch
if (!providerFetch)
throw new Error('Azure OpenAI did not create a fetch adapter.')
await providerFetch(new URL('https://example.openai.azure.com/openai/v1/chat/completions'), {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'test-deployment',
messages: [],
tools,
}),
})
const requestBody = fetchMock.mock.calls[0]?.[1]?.body
if (typeof requestBody !== 'string')
throw new Error('Azure OpenAI did not send a JSON request body.')
const body = JSON.parse(requestBody) as ChatRequestBody
const sparkTool = body.tools.find(tool => tool.function.name === 'builtIn_emitSparkCommand')
const contexts = getArraySchema(sparkTool?.function.parameters.properties?.contexts as JsonSchema)
const contextItem = contexts?.items as JsonSchema
const metadata = getArraySchema(contextItem.properties?.metadata as JsonSchema)
const metadataItem = metadata?.items as JsonSchema
const metadataValue = metadataItem.properties?.value as JsonSchema
// ROOT CAUSE:
//
// The provider-neutral spark schema keeps this value as a nullable `anyOf`.
// Azure OpenAI rejects that schema before generation and disables all tools.
//
// We fixed this in the Azure request adapter. It converts the union only for
// Azure OpenAI and leaves the canonical tool schema unchanged.
expect(metadataValue.type).toEqual(['string', 'number', 'boolean', 'null'])
expect(metadataValue.anyOf).toBeUndefined()
})
})
@@ -1,7 +1,10 @@
import type { JsonSchema } from 'xsschema'
import { errorMessageFrom } from '@moeru/std'
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { collapseToolSchemaPrimitiveAnyOf } from '../../tool-schema'
import { defineProvider } from '../registry'
const AZURE_OPENAI_PROVIDER_ID = 'azure-openai' as const
@@ -94,11 +97,51 @@ function resolveConfiguredDeployments(config: AzureOpenAIConfig): string[] {
return endpointHints.completionsDeployment ? [endpointHints.completionsDeployment] : []
}
function mapChatBodyToCompletions(body: any): Record<string, unknown> {
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function isJsonSchema(value: unknown): value is JsonSchema {
return isRecord(value)
}
/**
* Converts primitive tool unions to the form that Azure OpenAI accepts.
*
* @example
* normalizeAzureOpenAIChatTools([{
* function: { parameters: { anyOf: [{ type: 'string' }, { type: 'null' }] } },
* }])
* // => [{ function: { parameters: { type: ['string', 'null'] } } }]
*/
function normalizeAzureOpenAIChatTools(tools: unknown): unknown {
if (!Array.isArray(tools))
return tools
return tools.map((tool) => {
if (!isRecord(tool) || !isRecord(tool.function))
return tool
const parameters = tool.function.parameters
if (!isJsonSchema(parameters))
return tool
return {
...tool,
function: {
...tool.function,
parameters: collapseToolSchemaPrimitiveAnyOf(parameters),
},
}
})
}
function mapChatBodyToCompletions(body: Record<string, unknown>): Record<string, unknown> {
const mappedBody: Record<string, unknown> = {
...body,
messages: body?.messages,
max_completion_tokens: body?.max_completion_tokens ?? body?.max_output_tokens ?? body?.max_tokens,
messages: body.messages,
max_completion_tokens: body.max_completion_tokens ?? body.max_output_tokens ?? body.max_tokens,
tools: normalizeAzureOpenAIChatTools(body.tools),
}
delete mappedBody.input
@@ -121,12 +164,12 @@ function createAzureOpenAIFetch(config: AzureOpenAIConfig) {
return fetch(request)
}
const requestBody = await request.clone().json().catch(() => null)
if (!requestBody) {
const requestBody: unknown = await request.clone().json().catch(() => null)
if (!isRecord(requestBody)) {
return fetch(request)
}
const deployment = endpointHints.completionsDeployment || (typeof requestBody?.model === 'string' ? requestBody.model.trim() : '')
const deployment = endpointHints.completionsDeployment || (typeof requestBody.model === 'string' ? requestBody.model.trim() : '')
if (!deployment) {
return fetch(request)
}
@@ -0,0 +1,85 @@
import type { JsonSchema } from 'xsschema'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSparkCommandTool } from '../../../../tools/character/orchestrator/spark-command'
import { providerOpenRouterAI } from './index'
interface ChatRequestBody {
tools: Array<{
function: {
name: string
parameters: JsonSchema
}
}>
}
function isJsonSchema(value: JsonSchema | boolean | undefined): value is JsonSchema {
return Boolean(value && typeof value === 'object')
}
function getArraySchema(schema?: JsonSchema): JsonSchema | undefined {
if (!schema)
return undefined
if (schema.type === 'array')
return schema
return schema.anyOf?.filter(isJsonSchema).find(candidate => candidate.type === 'array')
}
describe('providerOpenRouterAI tool schemas', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('keeps the canonical nullable anyOf when it sends a chat request', async () => {
const tools = await createSparkCommandTool({
sendSparkCommand: () => undefined,
})
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response('{}'))
vi.stubGlobal('fetch', fetchMock)
const provider = providerOpenRouterAI.createProvider({
apiKey: 'test-key',
})
if (!('chat' in provider))
throw new Error('OpenRouter did not create a chat provider.')
const providerFetch = provider.chat('google/gemini-test').fetch
if (!providerFetch)
throw new Error('OpenRouter did not create a fetch adapter.')
await providerFetch(new URL('https://openrouter.ai/api/v1/chat/completions'), {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-test',
messages: [],
tools,
}),
})
const requestBody = fetchMock.mock.calls[0]?.[1]?.body
if (typeof requestBody !== 'string')
throw new Error('OpenRouter did not send a JSON request body.')
const body = JSON.parse(requestBody) as ChatRequestBody
const sparkTool = body.tools.find(tool => tool.function.name === 'builtIn_emitSparkCommand')
const contexts = getArraySchema(sparkTool?.function.parameters.properties?.contexts as JsonSchema)
const contextItem = contexts?.items as JsonSchema
const metadata = getArraySchema(contextItem.properties?.metadata as JsonSchema)
const metadataItem = metadata?.items as JsonSchema
const metadataValue = metadataItem.properties?.value as JsonSchema
expect(metadataValue.type).toBeUndefined()
expect(metadataValue.anyOf).toEqual([
{ type: 'string' },
{ type: 'number' },
{ type: 'boolean' },
{ type: 'null' },
])
})
})
@@ -0,0 +1,77 @@
import type { JsonSchema } from 'xsschema'
import { describe, expect, it } from 'vitest'
import { collapseToolSchemaPrimitiveAnyOf } from './tool-schema'
describe('collapseToolSchemaPrimitiveAnyOf', () => {
it('collapses nested heterogeneous primitive unions without changing the input', () => {
const schema: JsonSchema = {
type: 'object',
properties: {
value: {
anyOf: [
{ type: 'string' },
{ type: 'number' },
{ type: 'boolean' },
{ type: 'null' },
],
},
},
}
const normalized = collapseToolSchemaPrimitiveAnyOf(schema)
expect(normalized.properties?.value).toEqual({
type: ['string', 'number', 'boolean', 'null'],
})
expect(schema.properties?.value).toEqual({
anyOf: [
{ type: 'string' },
{ type: 'number' },
{ type: 'boolean' },
{ type: 'null' },
],
})
})
it('keeps object and array unions as anyOf', () => {
const normalized = collapseToolSchemaPrimitiveAnyOf({
anyOf: [
{
type: 'object',
properties: {
value: { type: 'string' },
},
},
{
type: 'array',
items: { type: 'string' },
},
{ type: 'null' },
],
})
expect(normalized.type).toBeUndefined()
expect(normalized.anyOf).toHaveLength(3)
})
it('keeps numeric constraints when it collapses a nullable number', () => {
const normalized = collapseToolSchemaPrimitiveAnyOf({
anyOf: [
{
type: 'integer',
minimum: 1,
maximum: 10,
},
{ type: 'null' },
],
})
expect(normalized).toEqual({
type: ['integer', 'null'],
minimum: 1,
maximum: 10,
})
})
})
@@ -0,0 +1,90 @@
import type { JsonSchema } from 'xsschema'
type JsonSchemaPrimitiveType = 'string' | 'number' | 'integer' | 'boolean' | 'null'
const JSON_SCHEMA_PRIMITIVE_TYPES: ReadonlySet<string> = new Set(['string', 'number', 'integer', 'boolean', 'null'])
function isJsonSchema(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema {
return Boolean(value && !Array.isArray(value) && typeof value === 'object')
}
function isJsonSchemaPrimitiveType(value: unknown): value is JsonSchemaPrimitiveType {
return typeof value === 'string' && JSON_SCHEMA_PRIMITIVE_TYPES.has(value)
}
/**
* Collapses primitive `anyOf` branches into one JSON Schema type array.
*
* Use this function in a provider adapter only after that provider rejects the
* canonical `anyOf` form.
*
* @example
* collapseToolSchemaPrimitiveAnyOf({
* anyOf: [{ type: 'string' }, { type: 'null' }],
* })
* // => { type: ['string', 'null'] }
*/
export function collapseToolSchemaPrimitiveAnyOf(schema: JsonSchema): JsonSchema {
const next: JsonSchema = { ...schema }
if (next.properties) {
const properties = Object.fromEntries(
Object.entries(next.properties).map(([key, value]) => {
if (!isJsonSchema(value))
return [key, value]
return [key, collapseToolSchemaPrimitiveAnyOf(value)]
}),
)
next.properties = properties
if (Array.isArray(next.required)) {
const propertyNames = new Set(Object.keys(properties))
next.required = next.required.filter(key => propertyNames.has(key))
if (next.required.length === 0)
delete next.required
}
}
if (Array.isArray(next.items)) {
next.items = next.items.map(item => isJsonSchema(item) ? collapseToolSchemaPrimitiveAnyOf(item) : item)
}
else if (isJsonSchema(next.items)) {
next.items = collapseToolSchemaPrimitiveAnyOf(next.items)
}
if (next.anyOf) {
next.anyOf = next.anyOf.map(value => isJsonSchema(value) ? collapseToolSchemaPrimitiveAnyOf(value) : value)
const normalizedEntries = next.anyOf.filter(isJsonSchema)
const primitiveTypes = normalizedEntries
.map(entry => entry.type)
.filter(isJsonSchemaPrimitiveType)
const dedupedPrimitiveTypes = [...new Set(primitiveTypes)]
if (
primitiveTypes.length === normalizedEntries.length
&& dedupedPrimitiveTypes.length > 0
) {
for (const entry of normalizedEntries) {
if (entry.type !== 'number' && entry.type !== 'integer')
continue
next.multipleOf ??= entry.multipleOf
next.minimum ??= entry.minimum
next.maximum ??= entry.maximum
next.exclusiveMinimum ??= entry.exclusiveMinimum
next.exclusiveMaximum ??= entry.exclusiveMaximum
}
delete next.anyOf
next.type = dedupedPrimitiveTypes
}
}
if (next.oneOf) {
next.oneOf = next.oneOf.map(value => isJsonSchema(value) ? collapseToolSchemaPrimitiveAnyOf(value) : value)
}
return next
}
@@ -1,15 +1,9 @@
import type { JsonSchema } from 'xsschema'
import z from 'zod/v4'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { rawTool } from '@xsai/tool'
import { describe, expect, it, vi } from 'vitest'
import { toJsonSchema } from 'xsschema'
import { normalizeNullableAnyOf } from '../../json-schema'
import { createSparkCommandTool } from './spark-command'
import { sparkNotifyCommandItemSchema } from './spark-command-shared'
function isJsonSchema(value: JsonSchema | boolean | undefined): value is JsonSchema {
return Boolean(value && typeof value === 'object')
@@ -55,85 +49,6 @@ function findObjectSchema(schema: JsonSchema | undefined, predicate: (schema: Js
}
describe('tools/character/orchestrator/spark-command', () => {
it('normalizes scalar|null anyOf into a type array', async () => {
const schemaTestUnion = await toJsonSchema(z.object({
testField: z.union([z.string(), z.null()]),
}))
const normalized = normalizeNullableAnyOf(schemaTestUnion as JsonSchema)
const testField = normalized.properties?.testField as JsonSchema
expect(testField.type).toEqual(['string', 'null'])
expect(testField.anyOf).toBeUndefined()
})
it('deduplicates primitive types after normalization', async () => {
const schemaTestUnion = await toJsonSchema(z.object({
testField: z.union([z.literal('force'), z.literal('soft'), z.literal(false)]),
}))
const normalized = normalizeNullableAnyOf(schemaTestUnion as JsonSchema)
const testField = normalized.properties?.testField as JsonSchema
expect(testField.type).toEqual(['string', 'boolean'])
expect(testField.anyOf).toBeUndefined()
})
it('removes required keys that are not declared in sibling properties', () => {
const normalized = normalizeNullableAnyOf({
type: 'object',
properties: {
contexts: {
anyOf: [
{
type: 'array',
items: {
type: 'object',
properties: {
metadata: {
anyOf: [
{
type: 'array',
items: {
type: 'object',
properties: {
key: { type: 'string' },
},
required: ['key', 'value'],
},
},
{ type: 'null' },
],
},
},
},
},
{ type: 'null' },
],
},
},
required: ['contexts'],
} as JsonSchema)
const contexts = getArraySchema(normalized.properties?.contexts as JsonSchema)
const contextItem = contexts?.items as JsonSchema
const metadata = getArraySchema(contextItem.properties?.metadata as JsonSchema)
const metadataItem = metadata?.items as JsonSchema
expect(metadataItem.required).toEqual(['key'])
})
it('should render sparkNotifyCommandItemSchema into correct schema', async () => {
const schemaTest = await toJsonSchema(sparkNotifyCommandItemSchema)
const normalized = normalizeNullableAnyOf(schemaTest as JsonSchema)
const res = rawTool({
name: 'test_tool',
strict: true,
parameters: normalized,
execute: () => ({ success: true }),
})
expect(res.function.parameters).toStrictEqual(normalized)
})
it('emits a strict parameter schema', async () => {
const tools = await createSparkCommandTool({
sendSparkCommand: () => undefined,
@@ -159,6 +74,35 @@ describe('tools/character/orchestrator/spark-command', () => {
expect(metadata.propertyNames).toBeUndefined()
})
it('preserves heterogeneous nullable metadata values as anyOf', async () => {
const tools = await createSparkCommandTool({
sendSparkCommand: () => undefined,
})
const schema = tools[0].function.parameters as JsonSchema
const contexts = getArraySchema(schema.properties?.contexts as JsonSchema)
const contextItem = contexts?.items as JsonSchema
const metadata = getArraySchema(contextItem.properties?.metadata as JsonSchema)
const metadataItem = metadata?.items as JsonSchema
const metadataValue = metadataItem.properties?.value as JsonSchema
// ROOT CAUSE:
//
// A global normalizer collapsed this union into `type: ['string', 'number',
// 'boolean', 'null']`. The Gemini conversion in OpenRouter then removed the
// metadata properties but kept the `required` keys.
//
// The tool now keeps the canonical `anyOf`. Provider adapters can convert
// this schema when their target rejects the canonical form.
expect(metadataValue.type).toBeUndefined()
expect(metadataValue.anyOf).toEqual([
{ type: 'string' },
{ type: 'number' },
{ type: 'boolean' },
{ type: 'null' },
])
})
it('uses explicit required keys for nested strict option objects', async () => {
const tools = await createSparkCommandTool({
sendSparkCommand: () => undefined,
@@ -5,7 +5,6 @@ import { rawTool } from '@xsai/tool'
import { nanoid } from 'nanoid'
import { toJsonSchema } from 'xsschema'
import { normalizeNullableAnyOf } from '../../json-schema'
import {
normalizeSparkCommandDestinations,
normalizeSparkCommandGuidanceOptions,
@@ -21,10 +20,9 @@ export interface CreateSparkCommandToolOptions {
}
export async function createSparkCommandTool(options: CreateSparkCommandToolOptions) {
// NOTICE: We intentionally bypass `tool(...)` here so we can normalize the generated
// JSON Schema before `strictJsonSchema(...)` finalizes it. This is required for providers
// like Azure that reject some `anyOf` nullable forms and strict-object optional-field shapes.
const parameters = normalizeNullableAnyOf(await toJsonSchema(sparkCommandToolSchema) as any)
// Keep the generated JSON Schema provider-neutral. Each provider adapter
// converts unsupported schema forms before it sends the request.
const parameters = await toJsonSchema(sparkCommandToolSchema)
return [
rawTool({
@@ -1,85 +0,0 @@
import type { JsonSchema } from 'xsschema'
// Scalar JSON Schema types that may safely collapse into a `type: [..., 'null']`
// union. Object/array types are intentionally excluded: collapsing them would
// drop their nested `required`/`items`/`additionalProperties` constraints.
const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null'])
function isJsonSchema(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema {
return Boolean(value && !Array.isArray(value) && typeof value === 'object')
}
/**
* Normalizes nullable scalar unions in a generated JSON Schema so strict
* OpenAI-compatible providers accept the tool schema.
*
* `xsschema` (and zod v4) emit a nullable scalar like `integer | null` as an
* `anyOf`, but some validators (e.g. Azure) reject that form while accepting
* `type: ['integer', 'null']`. This recurses through the schema and collapses
* only scalar-or-null `anyOf`s; object/array unions are left untouched so their
* nested `required`/`items` constraints survive provider validation.
*
* Before:
* - `{ anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }] }`
*
* After:
* - `{ type: ['integer', 'null'] }`
*
* NOTICE: the collapse drops sibling keywords carried on the scalar branch
* (`minimum`/`maximum`/`enum`), so callers that relied on those bounds must
* re-validate at runtime.
*/
export function normalizeNullableAnyOf(schema: JsonSchema): JsonSchema {
const next: JsonSchema = { ...schema }
if (next.properties) {
const properties = Object.fromEntries(
Object.entries(next.properties).map(([key, value]) => {
if (!isJsonSchema(value))
return [key, value]
return [key, normalizeNullableAnyOf(value)]
}),
)
next.properties = properties
if (Array.isArray(next.required)) {
const propertyNames = new Set(Object.keys(properties))
next.required = next.required.filter(key => propertyNames.has(key))
if (next.required.length === 0)
delete next.required
}
}
if (Array.isArray(next.items)) {
next.items = next.items.map(item => isJsonSchema(item) ? normalizeNullableAnyOf(item) : item)
}
else if (isJsonSchema(next.items)) {
next.items = normalizeNullableAnyOf(next.items)
}
if (next.anyOf) {
next.anyOf = next.anyOf.map(value => isJsonSchema(value) ? normalizeNullableAnyOf(value) : value)
const normalizedEntries = next.anyOf.filter(isJsonSchema)
const primitiveTypes = normalizedEntries
.map(entry => entry.type)
.filter((type): type is Exclude<JsonSchema['type'], JsonSchema['type'][]> => typeof type === 'string')
const dedupedPrimitiveTypes = [...new Set(primitiveTypes)]
if (
primitiveTypes.length === normalizedEntries.length
&& dedupedPrimitiveTypes.length > 0
&& dedupedPrimitiveTypes.every(type => type !== undefined && JSON_SCHEMA_NULLABLE_SCALAR_TYPES.has(type))
) {
delete next.anyOf
next.type = dedupedPrimitiveTypes as JsonSchema['type']
}
}
if (next.oneOf) {
next.oneOf = next.oneOf.map(value => isJsonSchema(value) ? normalizeNullableAnyOf(value) : value)
}
return next
}
+16 -9
View File
@@ -13,7 +13,10 @@ interface TavilyResult {
/** Minimal shape of the emitted tool JSON Schema this suite asserts against. */
interface ToolParametersSchema {
required?: string[]
properties?: Record<string, { type?: unknown }>
properties?: Record<string, {
type?: unknown
anyOf?: Array<{ type?: unknown }>
}>
}
function stubTavily(payload: { results?: TavilyResult[] } | string, ok = true, status = 200) {
@@ -141,8 +144,7 @@ describe('createWebSearchTools', () => {
expect(body.exclude_domains).toBeUndefined()
})
// normalizeNullableAnyOf drops the schema's 1..10 bound (it does not survive
// the anyOf -> type[] collapse), so the count must be clamped at runtime.
// Keep the runtime range check because rawTool does not validate tool input.
it('clamps out-of-range max_results at runtime', async () => {
const fetchMock = stubTavily({ results: [] })
@@ -208,15 +210,20 @@ describe('createWebSearchTools', () => {
.toThrow('web search failed: tavily returned a non-JSON response')
})
// Strict OpenAI-compatible providers reject `.optional()` properties and the
// anyOf-with-null form; the emitted schema must list every field as required
// and collapse scalar nullable unions to `type: ['x', 'null']`.
it('emits a provider-safe schema (all fields required, scalar nullables collapsed)', async () => {
it('emits a provider-neutral schema with every field required', async () => {
const [tool] = await createWebSearchTools({ apiKey: 'key' })
const parameters = (tool as { function?: { parameters?: ToolParametersSchema } }).function?.parameters
expect(parameters?.required).toEqual(expect.arrayContaining(['query', 'max_results', 'time_range', 'include_domains', 'exclude_domains']))
expect(parameters?.properties?.max_results?.type).toEqual(['integer', 'null'])
expect(parameters?.properties?.time_range?.type).toEqual(['string', 'null'])
expect(parameters?.properties?.max_results?.type).toBeUndefined()
expect(parameters?.properties?.max_results?.anyOf).toEqual([
expect.objectContaining({ type: 'integer' }),
{ type: 'null' },
])
expect(parameters?.properties?.time_range?.type).toBeUndefined()
expect(parameters?.properties?.time_range?.anyOf).toEqual([
expect.objectContaining({ type: 'string' }),
{ type: 'null' },
])
})
})
+4 -13
View File
@@ -4,8 +4,6 @@ import { rawTool } from '@xsai/tool'
import { toJsonSchema } from 'xsschema'
import { z } from 'zod/v4'
import { normalizeNullableAnyOf } from './json-schema'
/**
* Tavily search endpoint. The provider is fixed (never model-supplied) so this
* tool has no SSRF surface — the model only controls the query and filters.
@@ -38,8 +36,6 @@ interface SearchResult {
// Optional inputs are modelled as required-nullable (never `.optional()`): strict
// OpenAI-compatible providers reject tool schemas whose properties are missing
// from `required`, so mounting the tool could otherwise 400 the whole request.
// The generated schema is further run through normalizeNullableAnyOf (see the
// factory below) so scalar `x | null` unions ship as `type: ['x', 'null']`.
const webSearchParameters = z.object({
query: z.string().min(2).max(400).describe('The search query. Be specific; this is sent to a web search engine.'),
max_results: z.union([z.number().int().min(MIN_MAX_RESULTS).max(MAX_MAX_RESULTS), z.null()]).describe('How many results to return (1-10), or null for the default of 5.'),
@@ -206,13 +202,9 @@ function formatResults(query: string, results: SearchResult[]): string {
export async function createWebSearchTools(options: { apiKey: string, timeoutMs?: number }): Promise<Tool[]> {
const { apiKey, timeoutMs = DEFAULT_TIMEOUT_MS } = options
// NOTICE: build via rawTool (not tool()) so the generated JSON Schema can be
// normalized before strictJsonSchema finalizes it. normalizeNullableAnyOf
// collapses scalar `x | null` unions to `type: ['x', 'null']`, the form strict
// OpenAI-compatible providers (e.g. Azure) accept — the anyOf-with-null shape
// tool() would emit is rejected. Mirrors createSparkCommandTool. The collapse
// drops the scalar min/max bound on max_results, so it is clamped at runtime.
const parameters = normalizeNullableAnyOf(await toJsonSchema(webSearchParameters))
// Keep the generated JSON Schema provider-neutral. Each provider adapter
// converts unsupported schema forms before it sends the request.
const parameters = await toJsonSchema(webSearchParameters)
return [
rawTool({
@@ -224,8 +216,7 @@ export async function createWebSearchTools(options: { apiKey: string, timeoutMs?
parameters,
execute: async (rawInput, { abortSignal }: ToolExecuteOptions) => {
const input = rawInput as WebSearchInput
// normalizeNullableAnyOf drops the schema's 1..10 bound (it does not
// survive the anyOf→type[] collapse), so re-enforce it here.
// Keep the runtime range check because rawTool does not validate input.
const maxResults = Math.min(Math.max(MIN_MAX_RESULTS, Math.trunc(input.max_results ?? DEFAULT_MAX_RESULTS)), MAX_MAX_RESULTS)
// Compose the caller's abort (turn cancelled) with our own timeout so
// either can cancel the outbound fetch.