@goodandready/dsh-voice 0.9.46 → 0.9.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/chain.js +47 -14
- package/lib/config-compat.js +2 -0
- package/lib/errors.js +252 -0
- package/lib/host-routes.js +10 -1
- package/lib/index.js +40 -34
- package/lib/provider-http.js +13 -0
- package/lib/providers.js +7 -7
- package/lib/schema.js +65 -0
- package/package.json +1 -1
package/lib/chain.js
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
// Provider fallback chain. No network and no cordis — a pure function so
|
|
2
2
|
// failure behaviour is unit-testable.
|
|
3
|
-
|
|
4
|
-
function normalizeError(err) {
|
|
5
|
-
const cause = err?.cause?.message || err?.message || String(err)
|
|
6
|
-
return cause.slice(0, 200)
|
|
7
|
-
}
|
|
3
|
+
import { classifyProviderOutcome, shouldFallbackError, ChainExhaustedError } from './errors.js'
|
|
8
4
|
|
|
9
5
|
/**
|
|
10
6
|
* @param order {string[]} provider keys in attempt order
|
|
11
|
-
* @param providers {Record<string, () => Promise<{ok, provider?, text?, reason?}>>}
|
|
12
|
-
* @param onAttempt {(provider: string, res: {ok: boolean, tookMs: number, reason?: string}) => void} optional stats callback
|
|
7
|
+
* @param providers {Record<string, () => Promise<{ok, provider?, text?, reason?, code?, fallbackEligible?}>>}
|
|
8
|
+
* @param onAttempt {(provider: string, res: {ok: boolean, tookMs: number, reason?: string, code?: string}) => void} optional stats callback
|
|
13
9
|
* @returns {Promise<{provider: string, text: string, tookMs: number}>}
|
|
14
10
|
*/
|
|
15
11
|
export async function runChain(order, providers, onAttempt) {
|
|
16
12
|
const t0 = Date.now()
|
|
17
13
|
const keys = (Array.isArray(order) ? order : []).filter((k) => typeof providers[k] === 'function')
|
|
18
14
|
const errors = []
|
|
15
|
+
const attempts = []
|
|
19
16
|
for (const key of keys) {
|
|
20
17
|
const startT = Date.now()
|
|
21
18
|
try {
|
|
@@ -27,19 +24,55 @@ export async function runChain(order, providers, onAttempt) {
|
|
|
27
24
|
}
|
|
28
25
|
return { provider: out.provider || key, text: out.text, tookMs: Date.now() - t0 }
|
|
29
26
|
}
|
|
30
|
-
const
|
|
27
|
+
const classified = classifyProviderOutcome((out && out.provider) || key, out || { reason: 'unknown' })
|
|
28
|
+
const pName = classified.provider || key
|
|
31
29
|
if (typeof onAttempt === 'function') {
|
|
32
|
-
onAttempt(
|
|
30
|
+
onAttempt(pName, { ok: false, tookMs, reason: classified.message, code: classified.code })
|
|
31
|
+
}
|
|
32
|
+
attempts.push({
|
|
33
|
+
provider: pName,
|
|
34
|
+
code: classified.code,
|
|
35
|
+
reason: classified.message,
|
|
36
|
+
tookMs,
|
|
37
|
+
isRetryable: classified.isRetryable,
|
|
38
|
+
})
|
|
39
|
+
errors.push(`${pName}: ${classified.message}`)
|
|
40
|
+
if (!shouldFallbackError(classified)) {
|
|
41
|
+
throw classified
|
|
33
42
|
}
|
|
34
|
-
errors.push(`${(out && out.provider) || key}: ${reason}`)
|
|
35
43
|
} catch (err) {
|
|
36
44
|
const tookMs = Date.now() - startT
|
|
37
|
-
const
|
|
45
|
+
const classified = classifyProviderOutcome(key, err)
|
|
46
|
+
if (!shouldFallbackError(classified)) {
|
|
47
|
+
const alreadyRecorded = attempts.some(
|
|
48
|
+
(a) => a.provider === (classified.provider || key) && a.reason === classified.message
|
|
49
|
+
)
|
|
50
|
+
if (!alreadyRecorded && typeof onAttempt === 'function') {
|
|
51
|
+
onAttempt(classified.provider || key, {
|
|
52
|
+
ok: false,
|
|
53
|
+
tookMs,
|
|
54
|
+
reason: classified.message,
|
|
55
|
+
code: classified.code,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
throw classified
|
|
59
|
+
}
|
|
38
60
|
if (typeof onAttempt === 'function') {
|
|
39
|
-
onAttempt(key, { ok: false, tookMs, reason:
|
|
61
|
+
onAttempt(key, { ok: false, tookMs, reason: classified.message, code: classified.code })
|
|
40
62
|
}
|
|
41
|
-
|
|
63
|
+
attempts.push({
|
|
64
|
+
provider: key,
|
|
65
|
+
code: classified.code,
|
|
66
|
+
reason: classified.message,
|
|
67
|
+
tookMs,
|
|
68
|
+
isRetryable: classified.isRetryable,
|
|
69
|
+
})
|
|
70
|
+
errors.push(`${key}: ${classified.message}`)
|
|
42
71
|
}
|
|
43
72
|
}
|
|
44
|
-
|
|
73
|
+
const anyRetryable = attempts.some((a) => a.isRetryable)
|
|
74
|
+
throw new ChainExhaustedError(`all providers failed (${errors.join('; ')})`, {
|
|
75
|
+
attempts,
|
|
76
|
+
isRetryable: anyRetryable,
|
|
77
|
+
})
|
|
45
78
|
}
|
package/lib/config-compat.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { isVolatileRef, plainConfig, resolveConfig, markVolatile, makeVolatile } from './schema.js'
|
|
2
|
+
|
|
1
3
|
// Decode modern volatile Config references while retaining stable identities for
|
|
2
4
|
// the request hot path. Legacy cores pass ordinary resolved configuration.
|
|
3
5
|
// If ctx is provided, invalidation listeners cache the unwrapped tree so the hot
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// lib/errors.js — Typed domain error hierarchy and failure classification (#309)
|
|
2
|
+
|
|
3
|
+
export class VoiceError extends Error {
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} message
|
|
6
|
+
* @param {{
|
|
7
|
+
* code?: string,
|
|
8
|
+
* provider?: string,
|
|
9
|
+
* status?: number,
|
|
10
|
+
* isRetryable?: boolean,
|
|
11
|
+
* fallbackEligible?: boolean,
|
|
12
|
+
* retryAfterMs?: number,
|
|
13
|
+
* cause?: unknown
|
|
14
|
+
* }} [options]
|
|
15
|
+
*/
|
|
16
|
+
constructor(message, options = {}) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = new.target.name
|
|
19
|
+
this.code = options.code || 'VOICE_ERROR'
|
|
20
|
+
this.provider = options.provider || ''
|
|
21
|
+
if (typeof options.status === 'number') this.status = options.status
|
|
22
|
+
this.isRetryable = Boolean(options.isRetryable)
|
|
23
|
+
this.fallbackEligible = options.fallbackEligible !== false
|
|
24
|
+
if (typeof options.retryAfterMs === 'number') this.retryAfterMs = options.retryAfterMs
|
|
25
|
+
if (options.cause !== undefined) this.cause = options.cause
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the STT fallback chain should advance to the next provider.
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
shouldFallback() {
|
|
33
|
+
return this.fallbackEligible
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
toJSON() {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
code: this.code,
|
|
40
|
+
message: this.message,
|
|
41
|
+
...(this.provider ? { provider: this.provider } : {}),
|
|
42
|
+
...(this.status !== undefined ? { status: this.status } : {}),
|
|
43
|
+
isRetryable: this.isRetryable,
|
|
44
|
+
fallbackEligible: this.fallbackEligible,
|
|
45
|
+
...(this.retryAfterMs !== undefined ? { retryAfterMs: this.retryAfterMs } : {}),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ProviderAuthError extends VoiceError {
|
|
51
|
+
constructor(message, options = {}) {
|
|
52
|
+
super(message, {
|
|
53
|
+
...options,
|
|
54
|
+
code: options.code || 'PROVIDER_AUTH_ERROR',
|
|
55
|
+
isRetryable: false,
|
|
56
|
+
fallbackEligible: options.fallbackEligible !== false,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class ProviderQuotaError extends VoiceError {
|
|
62
|
+
constructor(message, options = {}) {
|
|
63
|
+
super(message, {
|
|
64
|
+
...options,
|
|
65
|
+
code: options.code || 'PROVIDER_QUOTA_ERROR',
|
|
66
|
+
isRetryable: false,
|
|
67
|
+
fallbackEligible: options.fallbackEligible !== false,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class ProviderRateLimitError extends VoiceError {
|
|
73
|
+
constructor(message, options = {}) {
|
|
74
|
+
super(message, {
|
|
75
|
+
...options,
|
|
76
|
+
code: options.code || 'PROVIDER_RATE_LIMIT',
|
|
77
|
+
isRetryable: true,
|
|
78
|
+
fallbackEligible: options.fallbackEligible !== false,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class ProviderServerError extends VoiceError {
|
|
84
|
+
constructor(message, options = {}) {
|
|
85
|
+
super(message, {
|
|
86
|
+
...options,
|
|
87
|
+
code: options.code || 'PROVIDER_SERVER_ERROR',
|
|
88
|
+
isRetryable: options.isRetryable ?? true,
|
|
89
|
+
fallbackEligible: options.fallbackEligible !== false,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class DaemonUnavailableError extends VoiceError {
|
|
95
|
+
constructor(message, options = {}) {
|
|
96
|
+
super(message, {
|
|
97
|
+
...options,
|
|
98
|
+
code: options.code || 'DAEMON_UNAVAILABLE',
|
|
99
|
+
isRetryable: false,
|
|
100
|
+
fallbackEligible: options.fallbackEligible !== false,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class AudioFormatError extends VoiceError {
|
|
106
|
+
constructor(message, options = {}) {
|
|
107
|
+
super(message, {
|
|
108
|
+
...options,
|
|
109
|
+
code: options.code || 'AUDIO_FORMAT_ERROR',
|
|
110
|
+
isRetryable: false,
|
|
111
|
+
fallbackEligible: Boolean(options.fallbackEligible),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class ChainExhaustedError extends VoiceError {
|
|
117
|
+
constructor(message, options = {}) {
|
|
118
|
+
super(message, {
|
|
119
|
+
...options,
|
|
120
|
+
code: options.code || 'CHAIN_EXHAUSTED',
|
|
121
|
+
isRetryable: Boolean(options.isRetryable),
|
|
122
|
+
fallbackEligible: false,
|
|
123
|
+
})
|
|
124
|
+
this.attempts = Array.isArray(options.attempts) ? options.attempts : []
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
toJSON() {
|
|
128
|
+
return {
|
|
129
|
+
...super.toJSON(),
|
|
130
|
+
attempts: this.attempts,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isQuotaText(detail = '') {
|
|
136
|
+
const lower = String(detail).toLowerCase()
|
|
137
|
+
return /\bquota\b/.test(lower)
|
|
138
|
+
|| lower.includes('usage limit')
|
|
139
|
+
|| lower.includes('usage_limit')
|
|
140
|
+
|| lower.includes('limit reached')
|
|
141
|
+
|| lower.includes('insufficient credit')
|
|
142
|
+
|| lower.includes('insufficient balance')
|
|
143
|
+
|| lower.includes('insufficient funds')
|
|
144
|
+
|| lower.includes('credit balance')
|
|
145
|
+
|| lower.includes('out of credit')
|
|
146
|
+
|| lower.includes('payment required')
|
|
147
|
+
|| lower.includes('billing')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseRetryAfterMs(headers) {
|
|
151
|
+
if (!headers || typeof headers.get !== 'function') return undefined
|
|
152
|
+
const raw = headers.get('retry-after')
|
|
153
|
+
if (raw === null || raw === undefined || String(raw).trim() === '') return undefined
|
|
154
|
+
const seconds = Number(raw)
|
|
155
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.trunc(seconds * 1000)
|
|
156
|
+
const at = Date.parse(raw)
|
|
157
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Classify an HTTP status and message into a typed VoiceError subclass.
|
|
162
|
+
* @param {string} provider
|
|
163
|
+
* @param {number} status
|
|
164
|
+
* @param {string} message
|
|
165
|
+
* @param {Headers} [headers]
|
|
166
|
+
* @returns {VoiceError}
|
|
167
|
+
*/
|
|
168
|
+
export function classifyHttpError(provider, status, message, headers) {
|
|
169
|
+
const msg = String(message || `HTTP ${status}`).trim()
|
|
170
|
+
const retryAfterMs = parseRetryAfterMs(headers)
|
|
171
|
+
const opts = { provider, status, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) }
|
|
172
|
+
|
|
173
|
+
if (status === 402 || (isQuotaText(msg) && status !== 401)) {
|
|
174
|
+
return new ProviderQuotaError(msg, opts)
|
|
175
|
+
}
|
|
176
|
+
if (status === 401 || status === 403) {
|
|
177
|
+
return new ProviderAuthError(msg, opts)
|
|
178
|
+
}
|
|
179
|
+
if (status === 429) {
|
|
180
|
+
return new ProviderRateLimitError(msg, opts)
|
|
181
|
+
}
|
|
182
|
+
if (status === 415 || status === 422) {
|
|
183
|
+
return new AudioFormatError(msg, { ...opts, fallbackEligible: true })
|
|
184
|
+
}
|
|
185
|
+
if (status === 408 || status === 504 || status >= 500) {
|
|
186
|
+
return new ProviderServerError(msg, opts)
|
|
187
|
+
}
|
|
188
|
+
return new VoiceError(msg, { ...opts, code: 'PROVIDER_HTTP_ERROR', isRetryable: false, fallbackEligible: true })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Classify a provider refusal object or thrown Error into a VoiceError.
|
|
193
|
+
* @param {string} provider
|
|
194
|
+
* @param {unknown} outcomeOrErr
|
|
195
|
+
* @returns {VoiceError}
|
|
196
|
+
*/
|
|
197
|
+
export function classifyProviderOutcome(provider, outcomeOrErr) {
|
|
198
|
+
if (outcomeOrErr instanceof VoiceError) {
|
|
199
|
+
if (!outcomeOrErr.provider && provider) outcomeOrErr.provider = provider
|
|
200
|
+
return outcomeOrErr
|
|
201
|
+
}
|
|
202
|
+
if (outcomeOrErr instanceof Error) {
|
|
203
|
+
const rawMsg = outcomeOrErr?.cause?.message || outcomeOrErr?.message || String(outcomeOrErr)
|
|
204
|
+
const msg = rawMsg.slice(0, 200)
|
|
205
|
+
const statusMatch = msg.match(/\bHTTP\s+(\d{3})\b/i)
|
|
206
|
+
if (statusMatch) {
|
|
207
|
+
return classifyHttpError(provider, Number(statusMatch[1]), msg)
|
|
208
|
+
}
|
|
209
|
+
if (isQuotaText(msg)) {
|
|
210
|
+
return new ProviderQuotaError(msg, { provider, cause: outcomeOrErr })
|
|
211
|
+
}
|
|
212
|
+
if (/econnrefused|enotfound|fetch failed|daemon|not running|offline/i.test(msg)) {
|
|
213
|
+
return new DaemonUnavailableError(msg, { provider, cause: outcomeOrErr })
|
|
214
|
+
}
|
|
215
|
+
return new VoiceError(msg, {
|
|
216
|
+
provider,
|
|
217
|
+
code: 'PROVIDER_RUNTIME_ERROR',
|
|
218
|
+
isRetryable: /timeout|timed out|aborted|network/i.test(msg),
|
|
219
|
+
fallbackEligible: true,
|
|
220
|
+
cause: outcomeOrErr,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
if (outcomeOrErr && typeof outcomeOrErr === 'object') {
|
|
224
|
+
const reason = String(outcomeOrErr.reason || outcomeOrErr.message || 'unknown').slice(0, 200)
|
|
225
|
+
const p = String(outcomeOrErr.provider || provider || '')
|
|
226
|
+
const fallbackEligible = outcomeOrErr.fallbackEligible !== false
|
|
227
|
+
if (outcomeOrErr.code === 'AUDIO_FORMAT_ERROR') {
|
|
228
|
+
return new AudioFormatError(reason, { provider: p, fallbackEligible })
|
|
229
|
+
}
|
|
230
|
+
if (/^no\s+[A-Z0-9_]+/i.test(reason) || /unauthorized|invalid.*key|forbidden/i.test(reason)) {
|
|
231
|
+
return new ProviderAuthError(reason, { provider: p, fallbackEligible })
|
|
232
|
+
}
|
|
233
|
+
if (isQuotaText(reason)) {
|
|
234
|
+
return new ProviderQuotaError(reason, { provider: p, fallbackEligible })
|
|
235
|
+
}
|
|
236
|
+
if (/offline|unreachable|not running|recognition runs in the page/i.test(reason)) {
|
|
237
|
+
return new DaemonUnavailableError(reason, { provider: p, fallbackEligible })
|
|
238
|
+
}
|
|
239
|
+
return new VoiceError(reason, {
|
|
240
|
+
provider: p,
|
|
241
|
+
code: outcomeOrErr.code || 'PROVIDER_REFUSAL',
|
|
242
|
+
isRetryable: false,
|
|
243
|
+
fallbackEligible,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
return new VoiceError(String(outcomeOrErr || 'unknown').slice(0, 200), { provider })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function shouldFallbackError(err) {
|
|
250
|
+
if (err instanceof VoiceError) return err.shouldFallback()
|
|
251
|
+
return true
|
|
252
|
+
}
|
package/lib/host-routes.js
CHANGED
|
@@ -357,7 +357,16 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
357
357
|
lowVolume: Boolean(payload.lowVolume),
|
|
358
358
|
})
|
|
359
359
|
}
|
|
360
|
-
writeJson(res, 502, {
|
|
360
|
+
writeJson(res, 502, {
|
|
361
|
+
ok: false,
|
|
362
|
+
error: {
|
|
363
|
+
code: 'chain',
|
|
364
|
+
domainCode: (e && e.code) || 'CHAIN_EXHAUSTED',
|
|
365
|
+
isRetryable: Boolean(e && e.isRetryable),
|
|
366
|
+
...(Array.isArray(e && e.attempts) ? { attempts: e.attempts } : {}),
|
|
367
|
+
message: String(e && e.message || e),
|
|
368
|
+
},
|
|
369
|
+
})
|
|
361
370
|
} finally {
|
|
362
371
|
clearTimeout(timer)
|
|
363
372
|
}
|
package/lib/index.js
CHANGED
|
@@ -23,48 +23,57 @@ import { createPolishText } from './polish.js'
|
|
|
23
23
|
import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
|
|
24
24
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
25
25
|
import { applyJargonDictionary, extractVoiceActions } from './normalize.js'
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
ChainEntry,
|
|
28
|
+
CustomProvider,
|
|
29
|
+
BaseConfig,
|
|
30
|
+
markVolatile,
|
|
31
|
+
makeVolatile as schemaMakeVolatile,
|
|
32
|
+
isVolatileRef,
|
|
33
|
+
plainConfig,
|
|
34
|
+
resolveConfig,
|
|
35
|
+
} from './schema.js'
|
|
36
|
+
import {
|
|
37
|
+
VoiceError,
|
|
38
|
+
ProviderAuthError,
|
|
39
|
+
ProviderQuotaError,
|
|
40
|
+
ProviderRateLimitError,
|
|
41
|
+
ProviderServerError,
|
|
42
|
+
DaemonUnavailableError,
|
|
43
|
+
AudioFormatError,
|
|
44
|
+
ChainExhaustedError,
|
|
45
|
+
} from './errors.js'
|
|
27
46
|
import { createLocalDaemons } from './local-daemon.js'
|
|
28
47
|
|
|
29
|
-
export {
|
|
48
|
+
export {
|
|
49
|
+
ChainEntry,
|
|
50
|
+
CustomProvider,
|
|
51
|
+
BaseConfig,
|
|
52
|
+
markVolatile,
|
|
53
|
+
isVolatileRef,
|
|
54
|
+
plainConfig,
|
|
55
|
+
resolveConfig,
|
|
56
|
+
VoiceError,
|
|
57
|
+
ProviderAuthError,
|
|
58
|
+
ProviderQuotaError,
|
|
59
|
+
ProviderRateLimitError,
|
|
60
|
+
ProviderServerError,
|
|
61
|
+
DaemonUnavailableError,
|
|
62
|
+
AudioFormatError,
|
|
63
|
+
ChainExhaustedError,
|
|
64
|
+
}
|
|
30
65
|
export const name = '@goodandready/dsh-voice'
|
|
31
66
|
export const NS = 'dsh-voice'
|
|
32
67
|
export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
|
|
33
68
|
|
|
34
69
|
function makeVolatile(schema) {
|
|
35
|
-
|
|
36
|
-
const dict = {}
|
|
37
|
-
for (const [k, v] of Object.entries(schema.dict)) {
|
|
38
|
-
dict[k] = (v && typeof v.volatile === 'function') ? v.volatile() : v
|
|
39
|
-
}
|
|
40
|
-
const res = z.object(dict)
|
|
41
|
-
if (typeof schema.description === 'function' && schema.meta?.description) {
|
|
42
|
-
return res.description(schema.meta.description)
|
|
43
|
-
}
|
|
44
|
-
return res
|
|
70
|
+
return schemaMakeVolatile(schema)
|
|
45
71
|
}
|
|
46
72
|
|
|
47
73
|
export const Config = typeof z.number().volatile === 'function'
|
|
48
74
|
? makeVolatile(BaseConfig)
|
|
49
75
|
: BaseConfig
|
|
50
76
|
|
|
51
|
-
export function isVolatileRef(value) {
|
|
52
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function plainConfig(value) {
|
|
56
|
-
while (typeof value === 'function') {
|
|
57
|
-
value = value()
|
|
58
|
-
}
|
|
59
|
-
if (isVolatileRef(value)) return plainConfig(value.get())
|
|
60
|
-
if (value === null || typeof value !== 'object') return value
|
|
61
|
-
if (value instanceof Date || value instanceof RegExp || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) {
|
|
62
|
-
return value
|
|
63
|
-
}
|
|
64
|
-
if (Array.isArray(value)) return value.map(plainConfig)
|
|
65
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
|
|
66
|
-
}
|
|
67
|
-
|
|
68
77
|
export function apply(ctx, baseConfig) {
|
|
69
78
|
let settingsService = null
|
|
70
79
|
let scope = null
|
|
@@ -84,12 +93,9 @@ export function apply(ctx, baseConfig) {
|
|
|
84
93
|
|
|
85
94
|
const live = () => {
|
|
86
95
|
try {
|
|
87
|
-
|
|
88
|
-
const unwrapped = plainConfig(raw)
|
|
89
|
-
const validated = BaseConfig(unwrapped)
|
|
90
|
-
return plainConfig(validated) ?? plainConfig(readEntryConfig())
|
|
96
|
+
return resolveConfig(getConfig() ?? readEntryConfig())
|
|
91
97
|
} catch {
|
|
92
|
-
return
|
|
98
|
+
return resolveConfig(readEntryConfig())
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
|
package/lib/provider-http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Pure helper functions for STT provider HTTP requests and responses.
|
|
2
|
+
import { classifyHttpError } from './errors.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Check if the language is unspecified or set to automatic detection.
|
|
@@ -62,3 +63,15 @@ export async function readErrorDetail(res, defaultLabel) {
|
|
|
62
63
|
} catch { /* not json */ }
|
|
63
64
|
return `${defaultLabel} ${detail}`
|
|
64
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extract and classify a non-2xx HTTP response into a typed VoiceError subclass.
|
|
69
|
+
* @param {Response} res
|
|
70
|
+
* @param {string} defaultLabel
|
|
71
|
+
* @param {string} [providerKey]
|
|
72
|
+
* @returns {Promise<import('./errors.js').VoiceError>}
|
|
73
|
+
*/
|
|
74
|
+
export async function readHttpError(res, defaultLabel, providerKey = '') {
|
|
75
|
+
const detail = await readErrorDetail(res, defaultLabel)
|
|
76
|
+
return classifyHttpError(providerKey || defaultLabel.toLowerCase(), res?.status || 500, detail, res?.headers)
|
|
77
|
+
}
|
package/lib/providers.js
CHANGED
|
@@ -68,8 +68,8 @@ const CHAT_AUDIO_PROMPT =
|
|
|
68
68
|
'Transcribe the audio verbatim. Reply with the transcript text only, '
|
|
69
69
|
+ 'without comments, quotes or formatting.'
|
|
70
70
|
|
|
71
|
-
import { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat } from './provider-http.js'
|
|
72
|
-
export { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat }
|
|
71
|
+
import { readErrorDetail, readHttpError, safeJson, isAutoLang, fileName, chatAudioFormat } from './provider-http.js'
|
|
72
|
+
export { readErrorDetail, readHttpError, safeJson, isAutoLang, fileName, chatAudioFormat }
|
|
73
73
|
|
|
74
74
|
export const DEFAULT_MODELS = {
|
|
75
75
|
deepgram: 'nova-2',
|
|
@@ -110,7 +110,7 @@ export function makeProviders(deps, req) {
|
|
|
110
110
|
signal,
|
|
111
111
|
keepalive: true,
|
|
112
112
|
})
|
|
113
|
-
if (!res.ok) throw
|
|
113
|
+
if (!res.ok) throw await readHttpError(res, 'Deepgram', 'deepgram')
|
|
114
114
|
let data
|
|
115
115
|
try { data = await safeJson(res, 'Deepgram') } catch (e) {
|
|
116
116
|
return { ok: false, provider: 'deepgram', reason: e.message }
|
|
@@ -135,7 +135,7 @@ export function makeProviders(deps, req) {
|
|
|
135
135
|
signal,
|
|
136
136
|
keepalive: true,
|
|
137
137
|
})
|
|
138
|
-
if (!res.ok) throw
|
|
138
|
+
if (!res.ok) throw await readHttpError(res, 'Groq', 'groq')
|
|
139
139
|
let data
|
|
140
140
|
try { data = await safeJson(res, 'Groq') } catch (e) {
|
|
141
141
|
return { ok: false, provider: 'groq', reason: e.message }
|
|
@@ -155,7 +155,7 @@ export function makeProviders(deps, req) {
|
|
|
155
155
|
signal,
|
|
156
156
|
keepalive: true,
|
|
157
157
|
})
|
|
158
|
-
if (!res.ok) throw
|
|
158
|
+
if (!res.ok) throw await readHttpError(res, 'HF', 'hf')
|
|
159
159
|
let data
|
|
160
160
|
try { data = await safeJson(res, 'HF') } catch (e) {
|
|
161
161
|
return { ok: false, provider: 'hf', reason: e.message }
|
|
@@ -289,7 +289,7 @@ export function makeProviders(deps, req) {
|
|
|
289
289
|
const res = await fetchImpl(`${base}/audio/transcriptions`, {
|
|
290
290
|
method: 'POST', headers, body: form, signal,
|
|
291
291
|
})
|
|
292
|
-
if (!res.ok) throw
|
|
292
|
+
if (!res.ok) throw await readHttpError(res, label, spec.key)
|
|
293
293
|
const data = await safeJson(res, label)
|
|
294
294
|
return (data?.text || '').trim()
|
|
295
295
|
}
|
|
@@ -322,7 +322,7 @@ export function makeProviders(deps, req) {
|
|
|
322
322
|
}),
|
|
323
323
|
signal,
|
|
324
324
|
})
|
|
325
|
-
if (!res.ok) throw
|
|
325
|
+
if (!res.ok) throw await readHttpError(res, label, spec.key)
|
|
326
326
|
const data = await safeJson(res, label)
|
|
327
327
|
return String(data?.choices?.[0]?.message?.content || '').trim()
|
|
328
328
|
}
|
package/lib/schema.js
CHANGED
|
@@ -181,3 +181,68 @@ export const BaseConfig = z.object({
|
|
|
181
181
|
voiceMacros: z.array(VoiceMacro).default([])
|
|
182
182
|
.description('User voice action macros (spoken trigger phrase -> structured prompt replacement).'),
|
|
183
183
|
})
|
|
184
|
+
|
|
185
|
+
export function markVolatile(schema) {
|
|
186
|
+
return (schema && typeof schema.volatile === 'function') ? schema.volatile() : schema
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function makeVolatile(schema) {
|
|
190
|
+
if (!schema || typeof schema !== 'object' || !schema.dict) return markVolatile(schema)
|
|
191
|
+
const dict = {}
|
|
192
|
+
for (const [k, v] of Object.entries(schema.dict)) {
|
|
193
|
+
dict[k] = markVolatile(v)
|
|
194
|
+
}
|
|
195
|
+
const res = z.object(dict)
|
|
196
|
+
if (typeof schema.description === 'function' && schema.meta?.description) {
|
|
197
|
+
return res.description(schema.meta.description)
|
|
198
|
+
}
|
|
199
|
+
return res
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const Config = typeof z.number().volatile === 'function'
|
|
203
|
+
? makeVolatile(BaseConfig)
|
|
204
|
+
: BaseConfig
|
|
205
|
+
|
|
206
|
+
export function isVolatileRef(value) {
|
|
207
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function plainConfig(value) {
|
|
211
|
+
while (typeof value === 'function') {
|
|
212
|
+
value = value()
|
|
213
|
+
}
|
|
214
|
+
if (isVolatileRef(value)) return plainConfig(value.get())
|
|
215
|
+
if (value === null || typeof value !== 'object') return value
|
|
216
|
+
if (value instanceof Date || value instanceof RegExp || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) {
|
|
217
|
+
return value
|
|
218
|
+
}
|
|
219
|
+
if (Array.isArray(value)) return value.map(plainConfig)
|
|
220
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Normalize raw configuration (including volatile boxes or partial settings)
|
|
225
|
+
* into a deterministic validated configuration object (#305).
|
|
226
|
+
*/
|
|
227
|
+
export function resolveConfig(rawConfig) {
|
|
228
|
+
const unwrapped = plainConfig(rawConfig ?? {})
|
|
229
|
+
const input = (unwrapped && typeof unwrapped === 'object' && !Array.isArray(unwrapped)) ? unwrapped : {}
|
|
230
|
+
let validated
|
|
231
|
+
try {
|
|
232
|
+
validated = plainConfig(BaseConfig(structuredClone(input)))
|
|
233
|
+
} catch {
|
|
234
|
+
const defaults = plainConfig(BaseConfig({}))
|
|
235
|
+
validated = { ...defaults, ...input }
|
|
236
|
+
if (typeof validated.lowVolumeThreshold !== 'number' || !Number.isFinite(validated.lowVolumeThreshold)) {
|
|
237
|
+
validated.lowVolumeThreshold = defaults.lowVolumeThreshold
|
|
238
|
+
} else {
|
|
239
|
+
validated.lowVolumeThreshold = Math.min(1, Math.max(0, validated.lowVolumeThreshold))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
...validated,
|
|
244
|
+
deepgramBaseUrl: String(validated.deepgramBaseUrl || 'https://api.deepgram.com').trim() || 'https://api.deepgram.com',
|
|
245
|
+
whisperUrl: String(validated.whisperUrl || 'http://127.0.0.1:8001/inference').trim() || 'http://127.0.0.1:8001/inference',
|
|
246
|
+
sensevoiceUrl: String(validated.sensevoiceUrl || 'http://127.0.0.1:6006/api/v1/asr').trim() || 'http://127.0.0.1:6006/api/v1/asr',
|
|
247
|
+
}
|
|
248
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.47",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|