From a24d2496e4c3d99031626dda1abcbda32691e1ba Mon Sep 17 00:00:00 2001 From: Neko Date: Thu, 20 Aug 2026 21:40:13 +0800 Subject: [PATCH] fix(stage-ui): normalize tool schemas per provider (#2330) --- .../providers/azure-openai/index.test.ts | 89 ++++++++++++++ .../providers/providers/azure-openai/index.ts | 55 ++++++++- .../providers/openrouter-ai/index.test.ts | 85 +++++++++++++ .../src/libs/providers/tool-schema.test.ts | 77 ++++++++++++ .../src/libs/providers/tool-schema.ts | 90 ++++++++++++++ .../orchestrator/spark-command.test.ts | 114 +++++------------- .../character/orchestrator/spark-command.ts | 8 +- packages/stage-ui/src/tools/json-schema.ts | 85 ------------- .../stage-ui/src/tools/web-search.test.ts | 25 ++-- packages/stage-ui/src/tools/web-search.ts | 17 +-- 10 files changed, 442 insertions(+), 203 deletions(-) create mode 100644 packages/stage-ui/src/libs/providers/providers/azure-openai/index.test.ts create mode 100644 packages/stage-ui/src/libs/providers/providers/openrouter-ai/index.test.ts create mode 100644 packages/stage-ui/src/libs/providers/tool-schema.test.ts create mode 100644 packages/stage-ui/src/libs/providers/tool-schema.ts delete mode 100644 packages/stage-ui/src/tools/json-schema.ts diff --git a/packages/stage-ui/src/libs/providers/providers/azure-openai/index.test.ts b/packages/stage-ui/src/libs/providers/providers/azure-openai/index.test.ts new file mode 100644 index 000000000..1e63f7f3c --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/azure-openai/index.test.ts @@ -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().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() + }) +}) diff --git a/packages/stage-ui/src/libs/providers/providers/azure-openai/index.ts b/packages/stage-ui/src/libs/providers/providers/azure-openai/index.ts index ab4e6ba04..0dbecd457 100644 --- a/packages/stage-ui/src/libs/providers/providers/azure-openai/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/azure-openai/index.ts @@ -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 { +function isRecord(value: unknown): value is Record { + 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): Record { const mappedBody: Record = { ...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) } diff --git a/packages/stage-ui/src/libs/providers/providers/openrouter-ai/index.test.ts b/packages/stage-ui/src/libs/providers/providers/openrouter-ai/index.test.ts new file mode 100644 index 000000000..a587d0f6d --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/openrouter-ai/index.test.ts @@ -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().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' }, + ]) + }) +}) diff --git a/packages/stage-ui/src/libs/providers/tool-schema.test.ts b/packages/stage-ui/src/libs/providers/tool-schema.test.ts new file mode 100644 index 000000000..754edaff5 --- /dev/null +++ b/packages/stage-ui/src/libs/providers/tool-schema.test.ts @@ -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, + }) + }) +}) diff --git a/packages/stage-ui/src/libs/providers/tool-schema.ts b/packages/stage-ui/src/libs/providers/tool-schema.ts new file mode 100644 index 000000000..931592d0f --- /dev/null +++ b/packages/stage-ui/src/libs/providers/tool-schema.ts @@ -0,0 +1,90 @@ +import type { JsonSchema } from 'xsschema' + +type JsonSchemaPrimitiveType = 'string' | 'number' | 'integer' | 'boolean' | 'null' + +const JSON_SCHEMA_PRIMITIVE_TYPES: ReadonlySet = 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 +} diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts index 4b8b7180d..171375dd8 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts @@ -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, diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts index 01e45ab27..849317712 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts @@ -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({ diff --git a/packages/stage-ui/src/tools/json-schema.ts b/packages/stage-ui/src/tools/json-schema.ts deleted file mode 100644 index 2053d7e6d..000000000 --- a/packages/stage-ui/src/tools/json-schema.ts +++ /dev/null @@ -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 => 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 -} diff --git a/packages/stage-ui/src/tools/web-search.test.ts b/packages/stage-ui/src/tools/web-search.test.ts index 0ec440165..65ec57864 100644 --- a/packages/stage-ui/src/tools/web-search.test.ts +++ b/packages/stage-ui/src/tools/web-search.test.ts @@ -13,7 +13,10 @@ interface TavilyResult { /** Minimal shape of the emitted tool JSON Schema this suite asserts against. */ interface ToolParametersSchema { required?: string[] - properties?: Record + properties?: Record + }> } 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' }, + ]) }) }) diff --git a/packages/stage-ui/src/tools/web-search.ts b/packages/stage-ui/src/tools/web-search.ts index 37d2af06e..0189510d9 100644 --- a/packages/stage-ui/src/tools/web-search.ts +++ b/packages/stage-ui/src/tools/web-search.ts @@ -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 { 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.