diff --git a/changelog.d/1616-openai-chat-tool-patterns.md b/changelog.d/1616-openai-chat-tool-patterns.md new file mode 100644 index 000000000..225d3f4a5 --- /dev/null +++ b/changelog.d/1616-openai-chat-tool-patterns.md @@ -0,0 +1,4 @@ +- Typed chat on OpenAI answers again: OpenAI refused every message because + `resolve_area`'s area-id pattern uses Unicode classes Chat Completions does + not accept. Chat leaves those patterns out; area ids are still checked + before the tool runs (samehkhamis, #1616). diff --git a/server/providers/openai/chat.js b/server/providers/openai/chat.js index 413d69ba9..6ca4648d6 100644 --- a/server/providers/openai/chat.js +++ b/server/providers/openai/chat.js @@ -64,6 +64,30 @@ function validMessages(messages) { return !pending.length; } +/** + * A tool schema without the `pattern`s Chat Completions refuses: it answers + * 400 for a whole request whose tool uses Unicode property classes (`\p{L}`), + * as `resolve_area`'s area ids do. Every argument is still checked against + * the action's own contract before the tool runs, so the model loses a hint, + * not a rule. Realtime voice accepts these patterns and keeps them. + */ +export function chatSchema(schema) { + if (Array.isArray(schema)) return schema.map(chatSchema); + if (!object(schema)) return schema; + return Object.fromEntries( + Object.entries(schema) + .filter( + ([key, value]) => + !( + key === 'pattern' && + typeof value === 'string' && + /\\[pP]\{/.test(value) + ), + ) + .map(([key, value]) => [key, chatSchema(value)]), + ); +} + export function createOpenAiChatHandler({ annotationGuidance, tools = GEV_REALTIME_TOOLS, @@ -126,7 +150,12 @@ export function createOpenAiChatHandler({ tools: offeredTools(tools, { overpass }).map( ({ name, description, parameters }) => ({ type: 'function', - function: { name, description, parameters, strict: false }, + function: { + name, + description, + parameters: chatSchema(parameters), + strict: false, + }, }), ), }, diff --git a/src/textChatServer.test.mjs b/src/textChatServer.test.mjs index 61b09e987..b7137bbdd 100644 --- a/src/textChatServer.test.mjs +++ b/src/textChatServer.test.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import { once } from 'node:events'; import { createGeminiChatHandler } from '../server/providers/gemini/chat.js'; -import { createOpenAiChatHandler } from '../server/providers/openai/chat.js'; +import { + chatSchema, + createOpenAiChatHandler, +} from '../server/providers/openai/chat.js'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import { realtimeDirectives, @@ -534,6 +537,11 @@ for (const [name, provider] of Object.entries(providers)) { assert.equal(offered.includes('osm_query'), overpass); assert.equal(prompt.includes('osm_query'), overpass); assert.ok(offered.includes('resolve_area')); + // Chat Completions answers 400 invalid_function_parameters for a + // pattern with Unicode property classes, as resolve_area's area ids + // use; OpenAI chat leaves those patterns out. + if (name === 'openai') + assert.doesNotMatch(JSON.stringify(body.tools), /\\\\[pP]\{/); } }); test(`${name} throttled replies say they are rate limited`, async (t) => { @@ -697,3 +705,25 @@ test('OpenAI empty tool arrays are removed before history is returned', async (t const body = await (await f.send()).json(); assert.equal(body.content.tool_calls, undefined); }); + +test('OpenAI chat schemas drop only the patterns Chat Completions refuses', () => { + assert.match(JSON.stringify(GEV_REALTIME_TOOLS), /\\\\[pP]\{/); + assert.deepEqual( + chatSchema({ + type: 'object', + properties: { + id: { type: 'string', pattern: '^ne:[\\p{L}]+$', maxLength: 80 }, + code: { type: 'string', pattern: '^[A-Z]{2}$' }, + list: { type: 'array', items: [{ pattern: '\\P{N}' }] }, + }, + }), + { + type: 'object', + properties: { + id: { type: 'string', maxLength: 80 }, + code: { type: 'string', pattern: '^[A-Z]{2}$' }, + list: { type: 'array', items: [{}] }, + }, + }, + ); +});