fix(stage-ui): normalize tool schemas per provider (#2330)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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,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.
|
||||
|
||||
Reference in New Issue
Block a user