fix(voice): send OpenAI Realtime tool schemas the API accepts

OpenAI Realtime now answers 400 invalid_function_parameters for a whole
session whose tool schema uses Unicode property classes, as resolve_area's
area-id pattern does, so voice never connects ("Failed to create Realtime
token"). The session sends its tools through chatSchema, the filter typed
chat adopted in #1616, which leaves out only \p{…} patterns; area ids are
still checked against the action's own contract before the tool runs.

Fixes #1618
This commit is contained in:
Andrés Valdez committed 2026-11-09 07:34:44 +00:00
1 parent 027c9616a5
commit 9f2dfdf820
3 files changed
+49 -11

No files matched your search

+32 -9
View File
@@ -20,12 +20,9 @@ import {
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import {
GEV_REALTIME_TOOLS,
offeredTools,
realtimeTools,
} from '../../server/providers/openai/tools.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { createDebugLogHandler } from '../../server/providers/openai/debug-log.js';
import { realtimeSessionTools } from '../../server/providers/openai/realtime.js';
import { attachVoiceResult } from '../voice/speech.js';
import { sanitizeDebugValue } from '../voice/realtimeDiagnostics.js';
import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js';
@@ -199,7 +196,10 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
sent.at(-1).session.instructions,
realtimeInstructions(guidance),
);
assert.deepEqual(sent.at(-1).session.tools, realtimeTools());
assert.deepEqual(
sent.at(-1).session.tools,
realtimeSessionTools(GEV_REALTIME_TOOLS),
);
}
assert.notEqual(sent[0].session.instructions, sent[1].session.instructions);
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
@@ -607,7 +607,10 @@ test('osm_query is offered only when an operator configured Overpass', async (t)
assert.doesNotMatch(plain.instructions, /osm_query/);
assert.ok(names(plain).includes('resolve_area'));
assert.ok(names(plain).includes('find_imagery'));
assert.deepEqual(configured.tools, GEV_REALTIME_TOOLS);
assert.deepEqual(
configured.tools,
realtimeSessionTools(GEV_REALTIME_TOOLS, { overpass: true }),
);
assert.match(configured.instructions, /osm_query/);
assert.equal(
configured.instructions,
@@ -656,7 +659,7 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the
);
assert.equal(response.status, 200);
// Without an operator Overpass, osm_query is left out of the session.
assert.deepEqual(sent.at(-1).session.tools, offeredTools(tools));
assert.deepEqual(sent.at(-1).session.tools, realtimeSessionTools(tools));
assert.ok(
!sent.at(-1).session.tools.some(({ name }) => name === 'osm_query'),
);
@@ -740,6 +743,26 @@ test('key setup writes only the supplied application root, retains request guard
assert.equal(existsSync(path.join(untouched, '.env')), false);
});
test('Realtime sessions leave out the Unicode property patterns OpenAI refuses (#1618)', async (t) => {
// OpenAI answers 400 invalid_function_parameters for a whole Realtime session
// whose tool schema uses `\p{…}`, as resolve_area's area ids do, so voice
// never connects. The session drops only those patterns, as typed chat does.
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const sent = [];
t.mock.method(globalThis, 'fetch', async (_url, options) => {
sent.push(JSON.parse(options.body).session);
return Response.json({ value: 'fixture-ephemeral' });
});
const response = await request(
install(openAiRealtimeProxy({})).get('/api/realtime/token'),
);
assert.equal(response.status, 200);
const { tools } = sent.at(-1);
assert.ok(tools.some(({ name }) => name === 'resolve_area'));
assert.doesNotMatch(JSON.stringify(tools), /\\\\[pP]\{/);
});
test('Realtime service configuration selects compatible endpoint/model without forwarding request model IDs or keys', async () => {
const handler = install(
openAiRealtimeProxy({
@@ -757,7 +780,7 @@ test('Realtime service configuration selects compatible endpoint/model without f
assert.equal(payload.session.model, 'configured-model');
assert.deepEqual(
payload.session.tools,
realtimeTools({ overpass: false }),
realtimeSessionTools(GEV_REALTIME_TOOLS),
);
return Response.json({ value: 'short-lived-fixture' });
},