mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
fix(stage-ui,stage-ui-spine): make optional tool params work with OpenAI strict mode (#3072)
This commit is contained in:
@@ -60,8 +60,10 @@ export const tools = [
|
||||
},
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Spine animation name (e.g. "idle", "walk", "celebrate"). Case-insensitive partial match accepted.'),
|
||||
loop: z.boolean().optional().describe('Whether the animation should loop. Defaults to true.'),
|
||||
oneShot: z.boolean().optional().describe('Play once on the emotion track instead of replacing the idle loop.'),
|
||||
// Required-nullable instead of `.optional()`: OpenAI strict mode wants every
|
||||
// property in `required`, or the whole request fails.
|
||||
loop: z.union([z.boolean(), z.null()]).describe('Whether the animation should loop, or null for the default (true, or false for a one-shot).'),
|
||||
oneShot: z.union([z.boolean(), z.null()]).describe('Play once on the emotion track instead of replacing the idle loop, or null for false.'),
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
@@ -153,6 +153,47 @@ describe('home assistant tools', () => {
|
||||
}, {})
|
||||
})
|
||||
|
||||
it('lists every property as required, so OpenAI strict mode accepts the tools', async () => {
|
||||
const mounted = await tools(createClient())
|
||||
|
||||
for (const entry of mounted.values()) {
|
||||
const parameters = entry.function.parameters as {
|
||||
properties?: Record<string, unknown>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
expect(parameters.required?.slice().sort()).toEqual(Object.keys(parameters.properties ?? {}).sort())
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a null domain and null data as not given', async () => {
|
||||
const callService = vi.fn(async () => [])
|
||||
const client = createClient({
|
||||
callService,
|
||||
listEntities: vi.fn(async () => [
|
||||
{ entityId: 'light.kitchen', state: 'on', attributes: {} },
|
||||
{ entityId: 'climate.hall', state: 'heat', attributes: {} },
|
||||
]),
|
||||
})
|
||||
const mounted = await tools(client)
|
||||
|
||||
const listed = JSON.parse(await execute(mounted.get('home_assistant_list_entities'), { domain: null }) as string)
|
||||
expect(listed.total).toBe(2)
|
||||
|
||||
await execute(mounted.get('home_assistant_call_service'), {
|
||||
domain: 'light',
|
||||
service: 'turn_on',
|
||||
entity_id: 'light.kitchen',
|
||||
data: null,
|
||||
})
|
||||
expect(callService).toHaveBeenCalledWith({
|
||||
domain: 'light',
|
||||
service: 'turn_on',
|
||||
entityId: 'light.kitchen',
|
||||
data: undefined,
|
||||
}, {})
|
||||
})
|
||||
|
||||
it('requires a target entity, so no call reaches a whole domain', async () => {
|
||||
const mounted = await tools(createClient())
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ function toSummary(entity: HomeAssistantEntity) {
|
||||
* The payload crosses into the main process and lands in a request body, so a
|
||||
* nested or non-object value is a caller mistake rather than something to send.
|
||||
*/
|
||||
function parseServiceData(raw: string | undefined) {
|
||||
function parseServiceData(raw: string | null | undefined) {
|
||||
if (!raw?.trim())
|
||||
return undefined
|
||||
|
||||
@@ -183,7 +183,9 @@ export async function createHomeAssistantTools(
|
||||
})
|
||||
},
|
||||
parameters: z.object({
|
||||
domain: z.string().optional().describe('Entity domain to filter by, for example "light" or "climate".'),
|
||||
// Optional inputs are required-nullable, not `.optional()`. OpenAI strict
|
||||
// mode wants every property in `required`, or the whole request fails.
|
||||
domain: z.union([z.string(), z.null()]).describe('Entity domain to filter by, for example "light" or "climate", or null for every domain.'),
|
||||
}).strict(),
|
||||
}),
|
||||
tool({
|
||||
@@ -267,7 +269,8 @@ export async function createHomeAssistantTools(
|
||||
entity_id: z.string().describe('Target entity id, for example "light.living_room". Always pass one.'),
|
||||
// NOTICE: `data` is z.string() (JSON) because z.record() emits `propertyNames`,
|
||||
// which OpenAI rejects. The same reason appears beside builtIn_mcpCallTool.
|
||||
data: z.string().optional().describe('Extra service fields as a JSON object string, for example {"brightness": 200}.'),
|
||||
// Required-nullable for OpenAI strict mode, as with `domain` above.
|
||||
data: z.union([z.string(), z.null()]).describe('Extra service fields as a JSON object string, for example {"brightness": 200}, or null for none.'),
|
||||
}).strict(),
|
||||
}),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user