mirror of
https://github.com/jambonz/verb-specifications.git
synced 2026-03-21 18:57:52 +00:00
better handling of synonyms and shortcuts (#127)
This commit is contained in:
@@ -4,6 +4,27 @@ const _specData = require('./specs');
|
|||||||
const specs = new Map();
|
const specs = new Map();
|
||||||
for (const key in _specData) { specs.set(key, _specData[key]); }
|
for (const key in _specData) { specs.set(key, _specData[key]); }
|
||||||
|
|
||||||
|
/* verb synonyms and shortcuts: maps alias verb names to their canonical form,
|
||||||
|
optionally injecting properties (e.g. vendor) into the verb data */
|
||||||
|
const verbTransforms = new Map([
|
||||||
|
['stream', {verb: 'listen'}],
|
||||||
|
['s2s', {verb: 'llm'}],
|
||||||
|
['openai_s2s', {verb: 'llm', properties: {vendor: 'openai'}}],
|
||||||
|
['microsoft_s2s', {verb: 'llm', properties: {vendor: 'microsoft'}}],
|
||||||
|
['google_s2s', {verb: 'llm', properties: {vendor: 'google'}}],
|
||||||
|
['elevenlabs_s2s', {verb: 'llm', properties: {vendor: 'elevenlabs'}}],
|
||||||
|
['deepgram_s2s', {verb: 'llm', properties: {vendor: 'deepgram'}}],
|
||||||
|
['voiceagent_s2s', {verb: 'llm', properties: {vendor: 'voiceagent'}}],
|
||||||
|
['ultravox_s2s', {verb: 'llm', properties: {vendor: 'ultravox'}}],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function applyVerbTransform(name, data) {
|
||||||
|
const transform = verbTransforms.get(name);
|
||||||
|
if (!transform) return {name, data};
|
||||||
|
const newData = transform.properties ? {...transform.properties, ...data} : data;
|
||||||
|
return {name: transform.verb, data: newData};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeJambones(logger, obj) {
|
function normalizeJambones(logger, obj) {
|
||||||
if (!Array.isArray(obj)) {
|
if (!Array.isArray(obj)) {
|
||||||
throw new Error('malformed jambonz payload: must be array');
|
throw new Error('malformed jambonz payload: must be array');
|
||||||
@@ -13,18 +34,22 @@ function normalizeJambones(logger, obj) {
|
|||||||
if (typeof tdata !== 'object') throw new Error('malformed jambonz payload: must be array of objects');
|
if (typeof tdata !== 'object') throw new Error('malformed jambonz payload: must be array of objects');
|
||||||
if ('verb' in tdata) {
|
if ('verb' in tdata) {
|
||||||
// {verb: 'say', text: 'foo..bar'..}
|
// {verb: 'say', text: 'foo..bar'..}
|
||||||
const name = tdata.verb;
|
|
||||||
const o = {};
|
const o = {};
|
||||||
Object.keys(tdata)
|
Object.keys(tdata)
|
||||||
.filter((k) => k !== 'verb')
|
.filter((k) => k !== 'verb')
|
||||||
.forEach((k) => o[k] = tdata[k]);
|
.forEach((k) => o[k] = tdata[k]);
|
||||||
|
const {name, data} = applyVerbTransform(tdata.verb, o);
|
||||||
const o2 = {};
|
const o2 = {};
|
||||||
o2[name] = o;
|
o2[name] = data;
|
||||||
document.push(o2);
|
document.push(o2);
|
||||||
}
|
}
|
||||||
else if (Object.keys(tdata).length === 1) {
|
else if (Object.keys(tdata).length === 1) {
|
||||||
// {'say': {..}}
|
// {'say': {..}}
|
||||||
document.push(tdata);
|
const key = Object.keys(tdata)[0];
|
||||||
|
const {name, data} = applyVerbTransform(key, tdata[key]);
|
||||||
|
const o2 = {};
|
||||||
|
o2[name] = data;
|
||||||
|
document.push(o2);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
logger.info(tdata, 'malformed jambonz payload: missing verb property');
|
logger.info(tdata, 'malformed jambonz payload: missing verb property');
|
||||||
|
|||||||
27
specs.json
27
specs.json
@@ -427,33 +427,6 @@
|
|||||||
"url"
|
"url"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"stream": {
|
|
||||||
"properties": {
|
|
||||||
"id": "string",
|
|
||||||
"actionHook": "object|string",
|
|
||||||
"auth": "#auth",
|
|
||||||
"finishOnKey": "string",
|
|
||||||
"maxLength": "number",
|
|
||||||
"metadata": "object",
|
|
||||||
"mixType": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["mono", "stereo", "mixed"]
|
|
||||||
},
|
|
||||||
"passDtmf": "boolean",
|
|
||||||
"playBeep": "boolean",
|
|
||||||
"disableBidirectionalAudio": "boolean",
|
|
||||||
"bidirectionalAudio": "#bidirectionalAudio",
|
|
||||||
"sampleRate": "number",
|
|
||||||
"timeout": "number",
|
|
||||||
"transcribe": "#transcribe",
|
|
||||||
"url": "string",
|
|
||||||
"wsAuth": "#auth",
|
|
||||||
"earlyMedia": "boolean"
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"url"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"llm": {
|
"llm": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": "string",
|
"id": "string",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const test = require('tape');
|
const test = require('tape');
|
||||||
const logger = require('pino')({level: process.env.JAMBONES_LOGLEVEL || 'error'});
|
const logger = require('pino')({level: process.env.JAMBONES_LOGLEVEL || 'error'});
|
||||||
const { validate } = require('..');
|
const { validate, normalizeJambones } = require('..');
|
||||||
|
|
||||||
test("validate correct verbs", async (t) => {
|
test("validate correct verbs", async (t) => {
|
||||||
|
|
||||||
@@ -664,6 +664,36 @@ test("validate correct verbs", async (t) => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"verb": "s2s",
|
||||||
|
"vendor": "openai",
|
||||||
|
"llmOptions": {
|
||||||
|
"model": "gpt-4o-realtime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"verb": "openai_s2s",
|
||||||
|
"llmOptions": {
|
||||||
|
"model": "gpt-4o-realtime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"verb": "google_s2s",
|
||||||
|
"llmOptions": {
|
||||||
|
"model": "gemini-2.0-flash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"verb": "elevenlabs_s2s",
|
||||||
|
"llmOptions": {
|
||||||
|
"agentId": "agent-123"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"verb": "stream",
|
||||||
|
"url": "wss://myrecorder.example.com/calls",
|
||||||
|
"mixType": "stereo"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"verb": "pipeline",
|
"verb": "pipeline",
|
||||||
"stt": {
|
"stt": {
|
||||||
@@ -728,6 +758,110 @@ test('invalid test', async (t) => {
|
|||||||
} catch(err) {
|
} catch(err) {
|
||||||
t.ok(1 == 1,'successfully validate verbs');
|
t.ok(1 == 1,'successfully validate verbs');
|
||||||
}
|
}
|
||||||
|
|
||||||
t.end();
|
t.end();
|
||||||
})
|
});
|
||||||
|
|
||||||
|
test('verb synonyms: stream is synonym for listen', async (t) => {
|
||||||
|
// "verb" format
|
||||||
|
const result1 = normalizeJambones(logger, [
|
||||||
|
{"verb": "stream", "url": "wss://example.com/calls", "mixType": "stereo"}
|
||||||
|
]);
|
||||||
|
t.equal(Object.keys(result1[0])[0], 'listen', 'stream verb is rewritten to listen');
|
||||||
|
t.equal(result1[0].listen.url, 'wss://example.com/calls', 'data is preserved');
|
||||||
|
|
||||||
|
// object-key format
|
||||||
|
const result2 = normalizeJambones(logger, [
|
||||||
|
{"stream": {"url": "wss://example.com/calls"}}
|
||||||
|
]);
|
||||||
|
t.equal(Object.keys(result2[0])[0], 'listen', 'stream key is rewritten to listen');
|
||||||
|
|
||||||
|
// validate passes
|
||||||
|
try {
|
||||||
|
validate(logger, [{"verb": "stream", "url": "wss://example.com/calls"}]);
|
||||||
|
t.pass('stream verb validates successfully');
|
||||||
|
} catch (err) {
|
||||||
|
t.fail('stream verb should validate: ' + err);
|
||||||
|
}
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verb synonyms: s2s is synonym for llm', async (t) => {
|
||||||
|
const result = normalizeJambones(logger, [
|
||||||
|
{"verb": "s2s", "vendor": "openai", "llmOptions": {"model": "gpt-4o"}}
|
||||||
|
]);
|
||||||
|
t.equal(Object.keys(result[0])[0], 'llm', 's2s verb is rewritten to llm');
|
||||||
|
t.equal(result[0].llm.vendor, 'openai', 'vendor is preserved');
|
||||||
|
t.equal(result[0].llm.llmOptions.model, 'gpt-4o', 'llmOptions preserved');
|
||||||
|
|
||||||
|
try {
|
||||||
|
validate(logger, [{"verb": "s2s", "vendor": "openai", "llmOptions": {"model": "gpt-4o"}}]);
|
||||||
|
t.pass('s2s verb validates successfully');
|
||||||
|
} catch (err) {
|
||||||
|
t.fail('s2s verb should validate: ' + err);
|
||||||
|
}
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vendor shortcuts: openai_s2s injects vendor', async (t) => {
|
||||||
|
const result = normalizeJambones(logger, [
|
||||||
|
{"verb": "openai_s2s", "llmOptions": {"model": "gpt-4o-realtime"}}
|
||||||
|
]);
|
||||||
|
t.equal(Object.keys(result[0])[0], 'llm', 'openai_s2s is rewritten to llm');
|
||||||
|
t.equal(result[0].llm.vendor, 'openai', 'vendor is injected');
|
||||||
|
t.equal(result[0].llm.llmOptions.model, 'gpt-4o-realtime', 'llmOptions preserved');
|
||||||
|
|
||||||
|
try {
|
||||||
|
validate(logger, [{"verb": "openai_s2s", "llmOptions": {"model": "gpt-4o-realtime"}}]);
|
||||||
|
t.pass('openai_s2s validates successfully');
|
||||||
|
} catch (err) {
|
||||||
|
t.fail('openai_s2s should validate: ' + err);
|
||||||
|
}
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vendor shortcuts: all vendors work', async (t) => {
|
||||||
|
const vendors = [
|
||||||
|
'openai', 'microsoft', 'google', 'elevenlabs', 'deepgram', 'voiceagent', 'ultravox'
|
||||||
|
];
|
||||||
|
for (const vendor of vendors) {
|
||||||
|
const verbName = `${vendor}_s2s`;
|
||||||
|
const result = normalizeJambones(logger, [
|
||||||
|
{"verb": verbName, "llmOptions": {}}
|
||||||
|
]);
|
||||||
|
t.equal(Object.keys(result[0])[0], 'llm', `${verbName} rewrites to llm`);
|
||||||
|
t.equal(result[0].llm.vendor, vendor, `${verbName} injects vendor=${vendor}`);
|
||||||
|
}
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vendor shortcuts: object-key format works', async (t) => {
|
||||||
|
const result = normalizeJambones(logger, [
|
||||||
|
{"google_s2s": {"llmOptions": {"model": "gemini-2.0-flash"}}}
|
||||||
|
]);
|
||||||
|
t.equal(Object.keys(result[0])[0], 'llm', 'google_s2s key is rewritten to llm');
|
||||||
|
t.equal(result[0].llm.vendor, 'google', 'vendor is injected');
|
||||||
|
t.equal(result[0].llm.llmOptions.model, 'gemini-2.0-flash', 'llmOptions preserved');
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vendor shortcuts: explicit vendor in data overrides injected vendor', async (t) => {
|
||||||
|
const result = normalizeJambones(logger, [
|
||||||
|
{"verb": "openai_s2s", "vendor": "custom", "llmOptions": {}}
|
||||||
|
]);
|
||||||
|
t.equal(result[0].llm.vendor, 'custom', 'explicit vendor takes precedence');
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-synonym verbs are unchanged', async (t) => {
|
||||||
|
const result = normalizeJambones(logger, [
|
||||||
|
{"verb": "say", "text": "hello"}
|
||||||
|
]);
|
||||||
|
t.equal(Object.keys(result[0])[0], 'say', 'say verb is not transformed');
|
||||||
|
|
||||||
|
const result2 = normalizeJambones(logger, [
|
||||||
|
{"llm": {"vendor": "openai", "llmOptions": {}}}
|
||||||
|
]);
|
||||||
|
t.equal(Object.keys(result2[0])[0], 'llm', 'llm verb is not transformed');
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user