From baf80cb84c26d05c9f4f11b0e3523e900cf2c28c Mon Sep 17 00:00:00 2001 From: Hoan Luu Huu <110280845+xquanluu@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:06:28 +0700 Subject: [PATCH] fix: speechmatics credential test ignored the configured region (#563) The speechmatics branch of the /test route destructured only api_key out of the decrypted credential, so testSpeechmaticsStt() passed realtimeUrl: undefined to the sdk. ConnectionConfigFull only applies an override when the value is truthy, so every credential was silently tested against wss://eu2.rt.speechmatics.com/v2 no matter which region was stored - a neu or wus key was judged by whatever eu2 happened to answer. - pass speechmatics_stt_uri through from the route - normalize it before handing it to the sdk. The field holds a bare hostname, which the sdk cannot use as-is: it appends the language, yielding "eu2.rt.speechmatics.com/en" and ERR_INVALID_URL. At call time the same value goes to mod_speechmatics_transcribe as SPEECHMATICS_HOST, which always connects over wss on port 443 with a path of /v2, so build exactly that url. Anything else - a full ws url, a port - now fails the test with an actionable message rather than passing against an endpoint only the test can reach, and a credential that has lost the field fails too: the feature server omits SPEECHMATICS_HOST when it is unset and the module refuses the session, so defaulting to a hosted region here would show a green check on a dead credential. - record the speechmatics STT result with sttTestResult(), not ttsTestResult(), which is what every other STT vendor in this file does; the tts column was being stamped for an STT-only vendor Adds coverage for the url building to the main test suite - it is the part of this that can be exercised without an api key. Co-authored-by: Claude Opus 5 (1M context) --- lib/routes/api/speech-credentials.js | 8 +++---- lib/utils/speech-utils.js | 25 +++++++++++++++++++- test/index.js | 1 + test/speechmatics-url.js | 35 ++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 test/speechmatics-url.js diff --git a/lib/routes/api/speech-credentials.js b/lib/routes/api/speech-credentials.js index 64cdbdd..50c64cb 100644 --- a/lib/routes/api/speech-credentials.js +++ b/lib/routes/api/speech-credentials.js @@ -924,15 +924,15 @@ router.get('/:sid/test', async(req, res) => { } } } else if (cred.vendor === 'speechmatics') { - const {api_key} = credential; + const {api_key, speechmatics_stt_uri} = credential; if (cred.use_for_stt) { try { - await testSpeechmaticsStt(logger, {api_key}); + await testSpeechmaticsStt(logger, {api_key, speechmatics_stt_uri}); results.stt.status = 'ok'; - SpeechCredential.ttsTestResult(sid, true); + SpeechCredential.sttTestResult(sid, true); } catch (err) { results.stt = {status: 'fail', reason: err.message}; - SpeechCredential.ttsTestResult(sid, false); + SpeechCredential.sttTestResult(sid, false); } } } else if (cred.vendor === 'playht') { diff --git a/lib/utils/speech-utils.js b/lib/utils/speech-utils.js index 4e0498b..523b132 100644 --- a/lib/utils/speech-utils.js +++ b/lib/utils/speech-utils.js @@ -83,11 +83,33 @@ const testSonioxStt = async(logger, credentials) => { }); }; +/** + * speechmatics_stt_uri is stored as a bare hostname - eu2.rt.speechmatics.com and friends for + * the hosted service, or the hostname of an on-prem container - while the sdk wants a full url + * that it appends the language to. At call time the stored value is handed to + * mod_speechmatics_transcribe as SPEECHMATICS_HOST, which always dials wss on port 443 with a + * path of /v2, so test exactly that. Accepting a shape the runtime cannot dial (a full url, a + * port) would report a credential as good that then fails on every call, and quietly falling + * back to a default host would do the same for a credential that has lost the field - the + * feature server omits SPEECHMATICS_HOST entirely when it is unset, and the module then + * refuses to start the session. + */ +const speechmaticsRealtimeUrl = (speechmatics_stt_uri) => { + const host = (speechmatics_stt_uri || '').trim(); + if (!host) throw new Error('speechmatics_stt_uri is not set on this speech credential'); + if (!/^[a-z0-9.-]+$/i.test(host)) { + throw new Error( + `invalid speechmatics_stt_uri '${host}': expected a bare hostname, e.g. eu2.rt.speechmatics.com`); + } + return `wss://${host}/v2`; +}; + const testSpeechmaticsStt = async(logger, credentials) => { const {api_key, speechmatics_stt_uri} = credentials; + const realtimeUrl = speechmaticsRealtimeUrl(speechmatics_stt_uri); return new Promise(async(resolve, reject) => { try { - const session = new RealtimeSession({ apiKey: api_key, realtimeUrl: speechmatics_stt_uri }); + const session = new RealtimeSession({ apiKey: api_key, realtimeUrl }); let transcription = ''; session.addListener('Error', (error) => { reject(error); @@ -1845,6 +1867,7 @@ module.exports = { testVerbioStt, getLanguagesAndVoicesForVendor, testSpeechmaticsStt, + speechmaticsRealtimeUrl, testCartesia, testVoxistStt, testOpenAiStt, diff --git a/test/index.js b/test/index.js index b9269d6..91bc003 100644 --- a/test/index.js +++ b/test/index.js @@ -10,6 +10,7 @@ require('./auth'); require('./sbcs'); require('./ms-teams'); require('./speech-credentials'); +require('./speechmatics-url'); require('./recent-calls'); require('./users'); require('./users-view-only'); diff --git a/test/speechmatics-url.js b/test/speechmatics-url.js new file mode 100644 index 0000000..028ae8c --- /dev/null +++ b/test/speechmatics-url.js @@ -0,0 +1,35 @@ +const test = require('tape'); +const {speechmaticsRealtimeUrl} = require('../lib/utils/speech-utils'); + +/* the runtime hands speechmatics_stt_uri to mod_speechmatics_transcribe as a bare hostname and + always dials wss on 443 with a path of /v2, so anything else must fail the credential test + rather than pass against a url only the test can reach +*/ +test('speechmatics-url', (t) => { + t.equal(speechmaticsRealtimeUrl('eu2.rt.speechmatics.com'), + 'wss://eu2.rt.speechmatics.com/v2', 'builds the url the runtime dials'); + t.equal(speechmaticsRealtimeUrl('wus.rt.speechmatics.com'), + 'wss://wus.rt.speechmatics.com/v2', 'honors the configured region'); + t.equal(speechmaticsRealtimeUrl('sm-container.internal'), + 'wss://sm-container.internal/v2', 'accepts an on-prem hostname'); + t.equal(speechmaticsRealtimeUrl(' neu.rt.speechmatics.com '), + 'wss://neu.rt.speechmatics.com/v2', 'trims surrounding whitespace'); + + for (const uri of [undefined, null, '', ' ']) { + t.throws(() => speechmaticsRealtimeUrl(uri), /is not set on this speech credential/, + `rejects a credential with no host: ${JSON.stringify(uri)}`); + } + + for (const uri of [ + 'wss://eu2.rt.speechmatics.com/v2', + 'https://eu2.rt.speechmatics.com', + 'sm-container.internal:9000', + 'eu2.rt.speechmatics.com/v2', + 'my host' + ]) { + t.throws(() => speechmaticsRealtimeUrl(uri), /expected a bare hostname/, + `rejects a shape the runtime cannot dial: ${uri}`); + } + + t.end(); +});