@vellumai/assistant 0.11.3-staging.2 → 0.11.3-staging.3
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/package.json +1 -1
- package/src/__tests__/call-controller.test.ts +120 -0
- package/src/__tests__/config-loader-backfill.test.ts +19 -0
- package/src/__tests__/config-schema.test.ts +139 -5
- package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
- package/src/__tests__/media-stream-output.test.ts +175 -0
- package/src/__tests__/media-stream-stt-session.test.ts +67 -0
- package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
- package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
- package/src/calls/call-controller.ts +30 -2
- package/src/calls/call-speech-output.ts +12 -4
- package/src/calls/call-transport.ts +18 -1
- package/src/calls/media-stream-output.ts +53 -5
- package/src/calls/media-stream-server.ts +12 -0
- package/src/calls/media-stream-stt-session.ts +34 -0
- package/src/calls/telephony-synthesis-language.ts +84 -0
- package/src/calls/tts-text-sanitizer.ts +12 -5
- package/src/calls/voice-session-bridge.ts +31 -3
- package/src/calls/voice-triage-escalate.ts +52 -5
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
- package/src/config/loader.ts +5 -0
- package/src/config/schemas/calls.ts +0 -4
- package/src/config/schemas/tts.ts +63 -0
- package/src/live-voice/__tests__/front-decision.test.ts +120 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
- package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
- package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
- package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
- package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
- package/src/live-voice/front-decision.ts +50 -3
- package/src/live-voice/live-voice-session.ts +202 -25
- package/src/live-voice/live-voice-tts.ts +18 -2
- package/src/live-voice/progress-phrases.ts +105 -2
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
- package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
- package/src/providers/speech-to-text/provider-catalog.ts +38 -0
- package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
- package/src/runtime/assistant-event-hub.ts +23 -0
- package/src/runtime/local-actor-identity.ts +18 -5
- package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
- package/src/runtime/routes/events-routes.ts +17 -16
- package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
- package/src/stt/__tests__/language-metadata.test.ts +85 -0
- package/src/stt/language-metadata.ts +65 -0
- package/src/stt/types.ts +16 -0
- package/src/tts/__tests__/provider-adapters.test.ts +147 -0
- package/src/tts/__tests__/speakable-segments.test.ts +470 -0
- package/src/tts/language-voices.ts +23 -0
- package/src/tts/providers/deepgram-provider.ts +3 -1
- package/src/tts/providers/elevenlabs-provider.ts +73 -1
- package/src/tts/providers/xai-provider.ts +28 -2
- package/src/tts/speakable-segments.ts +293 -23
- package/src/tts/synthesis-stream.ts +7 -0
- package/src/tts/types.ts +7 -0
- package/src/util/__tests__/language-subtag.test.ts +54 -0
- package/src/util/language-subtag.ts +43 -0
- package/src/util/unicode.ts +1 -1
|
@@ -465,6 +465,29 @@ export class AssistantEventHub {
|
|
|
465
465
|
return this.getClientById(clientId)?.actorPrincipalId;
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Whether the connection identified by `connectionId` is still an active
|
|
470
|
+
* client subscription that carries no `actorPrincipalId`.
|
|
471
|
+
*
|
|
472
|
+
* Drives the retrying SSE self-heal (`sse-actor-principal-heal.ts`): the loop
|
|
473
|
+
* re-checks before each attempt so it stops as soon as the connection closes,
|
|
474
|
+
* is replaced by a reconnect, or gets a principal from any path. Returns
|
|
475
|
+
* `false` for an unknown, inactive, or process-type connection.
|
|
476
|
+
*/
|
|
477
|
+
needsActorPrincipalHeal(connectionId: string): boolean {
|
|
478
|
+
for (const entry of this.subscribers) {
|
|
479
|
+
if (entry.connectionId !== connectionId) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
return (
|
|
483
|
+
entry.active &&
|
|
484
|
+
entry.type === "client" &&
|
|
485
|
+
entry.actorPrincipalId == null
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
468
491
|
/**
|
|
469
492
|
* Fill a missing `actorPrincipalId` on a live client subscription.
|
|
470
493
|
*
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { isHttpAuthDisabled } from "../config/env.js";
|
|
12
12
|
import {
|
|
13
13
|
getGuardianDelivery,
|
|
14
|
+
getGuardianDeliveryFresh,
|
|
14
15
|
guardianForChannel,
|
|
15
16
|
peekCachedGuardianDelivery,
|
|
16
17
|
} from "../contacts/guardian-delivery-reader.js";
|
|
@@ -52,11 +53,19 @@ export function buildLocalAuthContext(conversationId: string): AuthContext {
|
|
|
52
53
|
* Returns `undefined` when no vellum guardian binding exists (e.g. fresh
|
|
53
54
|
* install before bootstrap, or the gateway is unreachable). Callers should
|
|
54
55
|
* treat that case as "not yet available" and proceed without a principalId.
|
|
56
|
+
*
|
|
57
|
+
* `forceRefresh` bypasses the reader's TTL cache. A successful read that finds
|
|
58
|
+
* no binding is itself cached, and gateway-side binding writes do not
|
|
59
|
+
* invalidate the daemon's cache, so a caller that polls for a binding it
|
|
60
|
+
* expects to appear (the SSE actor-principal heal) must force the read or it
|
|
61
|
+
* re-reads the same empty answer until the TTL lapses.
|
|
55
62
|
*/
|
|
56
|
-
export async function findLocalGuardianPrincipalId(
|
|
57
|
-
|
|
58
|
-
> {
|
|
59
|
-
const list =
|
|
63
|
+
export async function findLocalGuardianPrincipalId(options?: {
|
|
64
|
+
forceRefresh?: boolean;
|
|
65
|
+
}): Promise<string | undefined> {
|
|
66
|
+
const list = options?.forceRefresh
|
|
67
|
+
? await getGuardianDeliveryFresh({ channelTypes: ["vellum"] })
|
|
68
|
+
: await getGuardianDelivery({ channelTypes: ["vellum"] });
|
|
60
69
|
if (!list) {
|
|
61
70
|
return undefined;
|
|
62
71
|
}
|
|
@@ -136,15 +145,19 @@ export function findLocalGuardianPrincipalIdFromStore(): string | undefined {
|
|
|
136
145
|
* `undefined` when dev-bypass is set but no guardian binding has been created
|
|
137
146
|
* yet (e.g. fresh install before bootstrap); callers must treat this the
|
|
138
147
|
* same as a missing principal.
|
|
148
|
+
*
|
|
149
|
+
* `forceRefresh` bypasses the guardian-delivery cache, for callers that poll
|
|
150
|
+
* for a binding they expect to appear. See {@link findLocalGuardianPrincipalId}.
|
|
139
151
|
*/
|
|
140
152
|
export async function resolveActorPrincipalIdForLocalGuardian(
|
|
141
153
|
rawHeader: string | undefined,
|
|
154
|
+
options?: { forceRefresh?: boolean },
|
|
142
155
|
): Promise<string | undefined> {
|
|
143
156
|
if (rawHeader !== "dev-bypass" || !isHttpAuthDisabled()) {
|
|
144
157
|
return rawHeader;
|
|
145
158
|
}
|
|
146
159
|
|
|
147
|
-
const guardianPrincipalId = await findLocalGuardianPrincipalId();
|
|
160
|
+
const guardianPrincipalId = await findLocalGuardianPrincipalId(options);
|
|
148
161
|
if (guardianPrincipalId) {
|
|
149
162
|
return guardianPrincipalId;
|
|
150
163
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the retrying SSE actor-principal self-heal.
|
|
3
|
+
*
|
|
4
|
+
* The invariant they pin: a single empty or failed guardian lookup must not
|
|
5
|
+
* strand the subscription without a principal. The read goes over the gateway
|
|
6
|
+
* IPC and comes back empty both when the transport fails and when no binding
|
|
7
|
+
* exists yet, and a subscription that stays principal-less rejects every
|
|
8
|
+
* host-proxy result its client submits with "Submitting actor does not match
|
|
9
|
+
* the target client's actor for this request" until it reconnects.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { startActorPrincipalHeal } from "../sse-actor-principal-heal.js";
|
|
14
|
+
|
|
15
|
+
/** All-zero schedule so the retries run without real wall-clock delay. */
|
|
16
|
+
const NO_DELAYS = [0, 0, 0];
|
|
17
|
+
|
|
18
|
+
/** Minimal hub double: `needs` flips to false once a principal is filled. */
|
|
19
|
+
function fakeHub(options?: { needs?: boolean }) {
|
|
20
|
+
let needs = options?.needs ?? true;
|
|
21
|
+
const filled: string[] = [];
|
|
22
|
+
return {
|
|
23
|
+
filled,
|
|
24
|
+
setNeeds(value: boolean) {
|
|
25
|
+
needs = value;
|
|
26
|
+
},
|
|
27
|
+
needsActorPrincipalHeal: mock(() => needs),
|
|
28
|
+
fillClientActorPrincipalId: mock((_connectionId: string, id: string) => {
|
|
29
|
+
filled.push(id);
|
|
30
|
+
needs = false;
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Let the heal's async loop run to completion. */
|
|
36
|
+
async function settle(): Promise<void> {
|
|
37
|
+
for (let i = 0; i < 10; i++) {
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("startActorPrincipalHeal", () => {
|
|
43
|
+
test("fills the principal on the first successful attempt", async () => {
|
|
44
|
+
const hub = fakeHub();
|
|
45
|
+
startActorPrincipalHeal({
|
|
46
|
+
hub,
|
|
47
|
+
connectionId: "conn-1",
|
|
48
|
+
resolve: () => Promise.resolve("guardian-real-id"),
|
|
49
|
+
delaysMs: NO_DELAYS,
|
|
50
|
+
});
|
|
51
|
+
await settle();
|
|
52
|
+
|
|
53
|
+
expect(hub.filled).toEqual(["guardian-real-id"]);
|
|
54
|
+
expect(hub.needsActorPrincipalHeal).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("retries after a lookup that resolves undefined (gateway unreachable)", async () => {
|
|
58
|
+
const hub = fakeHub();
|
|
59
|
+
let calls = 0;
|
|
60
|
+
startActorPrincipalHeal({
|
|
61
|
+
hub,
|
|
62
|
+
connectionId: "conn-1",
|
|
63
|
+
resolve: () => {
|
|
64
|
+
calls++;
|
|
65
|
+
// Two failed reads, then the gateway comes up.
|
|
66
|
+
return Promise.resolve(calls < 3 ? undefined : "guardian-real-id");
|
|
67
|
+
},
|
|
68
|
+
delaysMs: NO_DELAYS,
|
|
69
|
+
});
|
|
70
|
+
await settle();
|
|
71
|
+
|
|
72
|
+
expect(calls).toBe(3);
|
|
73
|
+
expect(hub.filled).toEqual(["guardian-real-id"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("retries after a lookup that rejects", async () => {
|
|
77
|
+
const hub = fakeHub();
|
|
78
|
+
let calls = 0;
|
|
79
|
+
startActorPrincipalHeal({
|
|
80
|
+
hub,
|
|
81
|
+
connectionId: "conn-1",
|
|
82
|
+
resolve: () => {
|
|
83
|
+
calls++;
|
|
84
|
+
return calls === 1
|
|
85
|
+
? Promise.reject(new Error("ipc timeout"))
|
|
86
|
+
: Promise.resolve("guardian-real-id");
|
|
87
|
+
},
|
|
88
|
+
delaysMs: NO_DELAYS,
|
|
89
|
+
});
|
|
90
|
+
await settle();
|
|
91
|
+
|
|
92
|
+
expect(calls).toBe(2);
|
|
93
|
+
expect(hub.filled).toEqual(["guardian-real-id"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("stops retrying once the connection no longer needs healing", async () => {
|
|
97
|
+
// Models a disconnect (or a reconnect that replaced this subscription)
|
|
98
|
+
// between attempts: the loop must not keep polling the gateway for a
|
|
99
|
+
// connection that is gone.
|
|
100
|
+
const hub = fakeHub();
|
|
101
|
+
let calls = 0;
|
|
102
|
+
startActorPrincipalHeal({
|
|
103
|
+
hub,
|
|
104
|
+
connectionId: "conn-1",
|
|
105
|
+
resolve: () => {
|
|
106
|
+
calls++;
|
|
107
|
+
hub.setNeeds(false);
|
|
108
|
+
return Promise.resolve(undefined);
|
|
109
|
+
},
|
|
110
|
+
delaysMs: NO_DELAYS,
|
|
111
|
+
});
|
|
112
|
+
await settle();
|
|
113
|
+
|
|
114
|
+
expect(calls).toBe(1);
|
|
115
|
+
expect(hub.filled).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("never starts a lookup when the connection is already healed or gone", async () => {
|
|
119
|
+
const hub = fakeHub({ needs: false });
|
|
120
|
+
const resolve = mock(() => Promise.resolve("guardian-real-id"));
|
|
121
|
+
startActorPrincipalHeal({
|
|
122
|
+
hub,
|
|
123
|
+
connectionId: "conn-1",
|
|
124
|
+
resolve,
|
|
125
|
+
delaysMs: NO_DELAYS,
|
|
126
|
+
});
|
|
127
|
+
await settle();
|
|
128
|
+
|
|
129
|
+
expect(resolve).toHaveBeenCalledTimes(0);
|
|
130
|
+
expect(hub.filled).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("gives up after the schedule is exhausted without filling", async () => {
|
|
134
|
+
const hub = fakeHub();
|
|
135
|
+
let calls = 0;
|
|
136
|
+
startActorPrincipalHeal({
|
|
137
|
+
hub,
|
|
138
|
+
connectionId: "conn-1",
|
|
139
|
+
resolve: () => {
|
|
140
|
+
calls++;
|
|
141
|
+
return Promise.resolve(undefined);
|
|
142
|
+
},
|
|
143
|
+
delaysMs: NO_DELAYS,
|
|
144
|
+
});
|
|
145
|
+
await settle();
|
|
146
|
+
|
|
147
|
+
expect(calls).toBe(NO_DELAYS.length);
|
|
148
|
+
expect(hub.filled).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("dispatches the first attempt synchronously", () => {
|
|
152
|
+
const hub = fakeHub();
|
|
153
|
+
const resolve = mock(() => Promise.resolve("guardian-real-id"));
|
|
154
|
+
startActorPrincipalHeal({
|
|
155
|
+
hub,
|
|
156
|
+
connectionId: "conn-1",
|
|
157
|
+
resolve,
|
|
158
|
+
delaysMs: NO_DELAYS,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// No await: the leading zero delay must not cost a macrotask, so a
|
|
162
|
+
// warm-cache heal lands before the stream delivers its first event.
|
|
163
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -59,6 +59,8 @@ let mockReResolve: { trustClass: string; sourceChannel: string } | null = null;
|
|
|
59
59
|
mock.module("../../../contacts/guardian-delivery-reader.js", () => ({
|
|
60
60
|
getGuardianDelivery: (_input?: { channelTypes?: string[] }) =>
|
|
61
61
|
Promise.resolve(mockGuardianList),
|
|
62
|
+
getGuardianDeliveryFresh: (_input?: { channelTypes?: string[] }) =>
|
|
63
|
+
Promise.resolve(mockGuardianList),
|
|
62
64
|
peekCachedGuardianDelivery: () => mockGuardianList ?? undefined,
|
|
63
65
|
guardianForChannel: (
|
|
64
66
|
list: Array<Record<string, unknown>>,
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
ServiceUnavailableError,
|
|
53
53
|
} from "./errors.js";
|
|
54
54
|
import { parseBody } from "./parse-body.js";
|
|
55
|
+
import { startActorPrincipalHeal } from "./sse-actor-principal-heal.js";
|
|
55
56
|
import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
|
|
56
57
|
|
|
57
58
|
const log = getLogger("events-routes");
|
|
@@ -455,28 +456,28 @@ export function handleSubscribeAssistantEvents(
|
|
|
455
456
|
throw err;
|
|
456
457
|
}
|
|
457
458
|
|
|
458
|
-
// Self-heal for dev-bypass connections: the sync resolution above reads
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
459
|
+
// Self-heal for dev-bypass connections: the sync resolution above reads only
|
|
460
|
+
// the guardian-delivery cache, which can be cold at connect time, and a
|
|
461
|
+
// subscription that carries no principal for its lifetime 403s every
|
|
462
|
+
// host-proxy result it submits. The heal retries on a bounded backoff
|
|
463
|
+
// (`sse-actor-principal-heal.ts`), fire-and-forget so the stream is not
|
|
464
|
+
// delayed, keyed by connectionId so a reconnect race cannot patch the
|
|
465
|
+
// subscription that replaced this one. The lookup forces a fresh gateway read
|
|
466
|
+
// because a cached empty result outlives the retry schedule.
|
|
464
467
|
if (
|
|
465
468
|
clientId &&
|
|
466
469
|
interfaceId &&
|
|
467
470
|
actorPrincipalId == null &&
|
|
468
471
|
rawActorPrincipalId?.trim() === "dev-bypass"
|
|
469
472
|
) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
// the next reconnect retries.
|
|
479
|
-
});
|
|
473
|
+
startActorPrincipalHeal({
|
|
474
|
+
hub,
|
|
475
|
+
connectionId: sub.connectionId,
|
|
476
|
+
resolve: () =>
|
|
477
|
+
resolveActorPrincipalIdForLocalGuardian("dev-bypass", {
|
|
478
|
+
forceRefresh: true,
|
|
479
|
+
}),
|
|
480
|
+
});
|
|
480
481
|
}
|
|
481
482
|
|
|
482
483
|
const stream = new ReadableStream<Uint8Array>(
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrying self-heal for an SSE client subscription that registered without an
|
|
3
|
+
* actor principal.
|
|
4
|
+
*
|
|
5
|
+
* In `DISABLE_HTTP_AUTH` deployments the SSE subscribe path cannot await, so it
|
|
6
|
+
* resolves the dev-bypass actor principal from an IO-free peek at the
|
|
7
|
+
* guardian-delivery cache. A cold cache registers the subscription with
|
|
8
|
+
* `actorPrincipalId: undefined`, and every host-proxy result that client
|
|
9
|
+
* submits then fails the same-actor gate with `missing_target`: a 403 that only
|
|
10
|
+
* a fresh registration clears.
|
|
11
|
+
*
|
|
12
|
+
* The heal resolves the guardian asynchronously and patches the live hub
|
|
13
|
+
* record. Each attempt can come back empty, since the lookup goes over the
|
|
14
|
+
* gateway IPC and yields nothing both when the transport fails (unreachable
|
|
15
|
+
* gateway, its 2s timeout, a pod still starting) and when no guardian binding
|
|
16
|
+
* exists yet, so attempts repeat on a bounded backoff. The loop stops on the
|
|
17
|
+
* first success, when the connection is gone or already carries a principal, or
|
|
18
|
+
* when the schedule is exhausted.
|
|
19
|
+
*
|
|
20
|
+
* Callers must pass a cache-bypassing lookup: the guardian-delivery reader
|
|
21
|
+
* caches a successful empty result for minutes, which outlives the whole
|
|
22
|
+
* schedule, so a cached read would spend every attempt on the same stale
|
|
23
|
+
* answer.
|
|
24
|
+
*/
|
|
25
|
+
import { getLogger } from "../../util/logger.js";
|
|
26
|
+
|
|
27
|
+
const log = getLogger("sse-actor-heal");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The hub surface this module needs: a liveness check and the fill. Narrow by
|
|
31
|
+
* design so tests can drive the loop without a real hub.
|
|
32
|
+
*/
|
|
33
|
+
export interface ActorPrincipalHealHub {
|
|
34
|
+
needsActorPrincipalHeal(connectionId: string): boolean;
|
|
35
|
+
fillClientActorPrincipalId(
|
|
36
|
+
connectionId: string,
|
|
37
|
+
actorPrincipalId: string,
|
|
38
|
+
): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Delay before each attempt, in milliseconds. The leading zero dispatches the
|
|
43
|
+
* first attempt inline with the subscribe; the rest back off to cover a gateway
|
|
44
|
+
* that is slow to come up, without holding a timer for the life of a long-lived
|
|
45
|
+
* stream. Total span is about 44s.
|
|
46
|
+
*/
|
|
47
|
+
export const ACTOR_PRINCIPAL_HEAL_DELAYS_MS: readonly number[] = [
|
|
48
|
+
0, 1_000, 3_000, 10_000, 30_000,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start the retrying heal for `connectionId`. Fire-and-forget: never awaited by
|
|
53
|
+
* the route, never delays the stream.
|
|
54
|
+
*
|
|
55
|
+
* `resolve` performs the daemon's own server-side guardian lookup, bypassing
|
|
56
|
+
* the guardian-delivery cache. The value must never come from client input. It
|
|
57
|
+
* may resolve `undefined` (no binding yet, or the gateway is unreachable) or
|
|
58
|
+
* reject; both count as a failed attempt.
|
|
59
|
+
*/
|
|
60
|
+
export function startActorPrincipalHeal(args: {
|
|
61
|
+
hub: ActorPrincipalHealHub;
|
|
62
|
+
connectionId: string;
|
|
63
|
+
resolve: () => Promise<string | undefined>;
|
|
64
|
+
/** Override the backoff schedule. Tests pass all-zero delays. */
|
|
65
|
+
delaysMs?: readonly number[];
|
|
66
|
+
}): void {
|
|
67
|
+
const { hub, connectionId, resolve } = args;
|
|
68
|
+
const delays = args.delaysMs ?? ACTOR_PRINCIPAL_HEAL_DELAYS_MS;
|
|
69
|
+
|
|
70
|
+
void (async () => {
|
|
71
|
+
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
72
|
+
const delayMs = delays[attempt] ?? 0;
|
|
73
|
+
if (delayMs > 0) {
|
|
74
|
+
await sleep(delayMs);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Re-check every pass: the connection may have closed, been replaced by a
|
|
78
|
+
// reconnect, or been healed by another path since the last one.
|
|
79
|
+
if (!hub.needsActorPrincipalHeal(connectionId)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let resolved: string | undefined;
|
|
84
|
+
try {
|
|
85
|
+
resolved = await resolve();
|
|
86
|
+
} catch {
|
|
87
|
+
resolved = undefined;
|
|
88
|
+
}
|
|
89
|
+
if (resolved) {
|
|
90
|
+
hub.fillClientActorPrincipalId(connectionId, resolved);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Log the give-up so a principal-less subscription is greppable next to the
|
|
96
|
+
// same-actor rejections it causes, rather than surfacing only as opaque
|
|
97
|
+
// 403s on the client.
|
|
98
|
+
if (hub.needsActorPrincipalHeal(connectionId)) {
|
|
99
|
+
log.warn(
|
|
100
|
+
{ connectionId, attempts: delays.length },
|
|
101
|
+
"gave up healing missing actorPrincipalId for client subscription; host-proxy results from this connection are rejected until it reconnects",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sleep(ms: number): Promise<void> {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const timer = setTimeout(resolve, ms);
|
|
110
|
+
// Never hold the process (or a test runner) open on a pending backoff.
|
|
111
|
+
(timer as { unref?: () => void }).unref?.();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
dominantLanguageTag,
|
|
5
|
+
rankLanguages,
|
|
6
|
+
voteDominantLanguage,
|
|
7
|
+
} from "../language-metadata.js";
|
|
8
|
+
|
|
9
|
+
describe("voteDominantLanguage", () => {
|
|
10
|
+
test("only the dominance-ranked first entry votes", () => {
|
|
11
|
+
const tally = new Map<string, number>();
|
|
12
|
+
voteDominantLanguage(tally, ["en", "es"]);
|
|
13
|
+
expect(tally.get("en")).toBe(1);
|
|
14
|
+
expect(tally.has("es")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("regional variants count toward their base subtag", () => {
|
|
18
|
+
const tally = new Map<string, number>();
|
|
19
|
+
voteDominantLanguage(tally, ["pt-BR"]);
|
|
20
|
+
voteDominantLanguage(tally, ["pt"]);
|
|
21
|
+
expect(tally.get("pt")).toBe(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("underscore-separated variants count toward their base subtag", () => {
|
|
25
|
+
const tally = new Map<string, number>();
|
|
26
|
+
voteDominantLanguage(tally, ["hi_IN"]);
|
|
27
|
+
expect(tally.get("hi")).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("blank and absent tags cast no vote", () => {
|
|
31
|
+
const tally = new Map<string, number>();
|
|
32
|
+
voteDominantLanguage(tally, undefined);
|
|
33
|
+
voteDominantLanguage(tally, []);
|
|
34
|
+
voteDominantLanguage(tally, [" "]);
|
|
35
|
+
expect(tally.size).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("dominantLanguageTag", () => {
|
|
40
|
+
test("most votes wins", () => {
|
|
41
|
+
const tally = new Map([
|
|
42
|
+
["en", 1],
|
|
43
|
+
["es", 3],
|
|
44
|
+
]);
|
|
45
|
+
expect(dominantLanguageTag(tally)).toBe("es");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ties break by first insertion", () => {
|
|
49
|
+
const tally = new Map([
|
|
50
|
+
["hi", 2],
|
|
51
|
+
["en", 2],
|
|
52
|
+
]);
|
|
53
|
+
expect(dominantLanguageTag(tally)).toBe("hi");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("undefined for an empty tally", () => {
|
|
57
|
+
expect(dominantLanguageTag(new Map())).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("rankLanguages", () => {
|
|
62
|
+
test("returns empty array for empty input", () => {
|
|
63
|
+
expect(rankLanguages([])).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("ranks by frequency, most frequent first", () => {
|
|
67
|
+
expect(rankLanguages(["es", "en", "en", "en", "es"])).toEqual(["en", "es"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("breaks ties by first appearance", () => {
|
|
71
|
+
expect(rankLanguages(["hi", "en", "en", "hi"])).toEqual(["hi", "en"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("counts regional variants toward their base subtag", () => {
|
|
75
|
+
expect(rankLanguages(["en-US", "es", "en", "en-GB"])).toEqual(["en", "es"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("skips blank tags", () => {
|
|
79
|
+
expect(rankLanguages(["", " ", "ja"])).toEqual(["ja"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("accepts any iterable", () => {
|
|
83
|
+
expect(rankLanguages(new Set(["fr", "de"]))).toEqual(["fr", "de"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for tallying and ranking detected-language tags emitted
|
|
3
|
+
* by streaming STT providers (e.g. Deepgram nova-3 `multi` per-word tags).
|
|
4
|
+
*
|
|
5
|
+
* Tag normalization is `baseLanguageSubtag` (util/language-subtag.ts);
|
|
6
|
+
* these helpers layer vote-counting on top of it so provider adapters and
|
|
7
|
+
* event consumers share one notion of a dominant language.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { baseLanguageSubtag } from "../util/language-subtag.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cast one vote into a language tally for a transcript chunk's dominant
|
|
14
|
+
* detected language. `languages` is dominance-ranked, so only the first
|
|
15
|
+
* entry votes: secondary tags at full weight would let a minority
|
|
16
|
+
* language outvote the dominant one. Blank and absent tags cast no vote;
|
|
17
|
+
* regional variants count toward their base subtag.
|
|
18
|
+
*/
|
|
19
|
+
export function voteDominantLanguage(
|
|
20
|
+
tally: Map<string, number>,
|
|
21
|
+
languages: readonly string[] | undefined,
|
|
22
|
+
): void {
|
|
23
|
+
const dominant = baseLanguageSubtag(languages?.[0]);
|
|
24
|
+
if (dominant !== undefined) {
|
|
25
|
+
tally.set(dominant, (tally.get(dominant) ?? 0) + 1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The dominant tag in a language tally: most votes wins, ties break by
|
|
31
|
+
* first insertion (Map iteration is insertion order). Undefined for an
|
|
32
|
+
* empty tally.
|
|
33
|
+
*/
|
|
34
|
+
export function dominantLanguageTag(
|
|
35
|
+
tally: ReadonlyMap<string, number>,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
let dominant: string | undefined;
|
|
38
|
+
let dominantCount = 0;
|
|
39
|
+
for (const [tag, count] of tally) {
|
|
40
|
+
if (count > dominantCount) {
|
|
41
|
+
dominant = tag;
|
|
42
|
+
dominantCount = count;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return dominant;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tally normalized language tags and return them most-frequent-first.
|
|
50
|
+
* Ties are broken by first appearance in the input. Blank tags are
|
|
51
|
+
* skipped; regional variants count toward their base subtag.
|
|
52
|
+
*/
|
|
53
|
+
export function rankLanguages(tags: Iterable<string>): string[] {
|
|
54
|
+
// Map iteration order is insertion order, so a stable sort by count
|
|
55
|
+
// leaves tied tags in first-appearance order.
|
|
56
|
+
const counts = new Map<string, number>();
|
|
57
|
+
for (const tag of tags) {
|
|
58
|
+
const normalized = baseLanguageSubtag(tag);
|
|
59
|
+
if (normalized === undefined) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
|
|
63
|
+
}
|
|
64
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([tag]) => tag);
|
|
65
|
+
}
|
package/src/stt/types.ts
CHANGED
|
@@ -263,6 +263,14 @@ export interface SttStreamServerPartialEvent {
|
|
|
263
263
|
* provider does not surface confidence on interim chunks.
|
|
264
264
|
*/
|
|
265
265
|
readonly confidence?: number;
|
|
266
|
+
/**
|
|
267
|
+
* All detected languages of this chunk in dominance order (first entry
|
|
268
|
+
* dominant), already normalized to lowercase base subtags (e.g. "en",
|
|
269
|
+
* "hi") by the emitting adapter; consumers must not re-normalize. Only
|
|
270
|
+
* providers with code-switching metadata (currently Deepgram nova-3
|
|
271
|
+
* `multi`) populate this. Absence means "unknown", never English.
|
|
272
|
+
*/
|
|
273
|
+
readonly languages?: readonly string[];
|
|
266
274
|
}
|
|
267
275
|
|
|
268
276
|
/**
|
|
@@ -279,6 +287,14 @@ export interface SttStreamServerFinalEvent {
|
|
|
279
287
|
* provider does not surface confidence on this chunk.
|
|
280
288
|
*/
|
|
281
289
|
readonly confidence?: number;
|
|
290
|
+
/**
|
|
291
|
+
* All detected languages of this chunk in dominance order (first entry
|
|
292
|
+
* dominant), already normalized to lowercase base subtags (e.g. "en",
|
|
293
|
+
* "hi") by the emitting adapter; consumers must not re-normalize. Only
|
|
294
|
+
* providers with code-switching metadata (currently Deepgram nova-3
|
|
295
|
+
* `multi`) populate this. Absence means "unknown", never English.
|
|
296
|
+
*/
|
|
297
|
+
readonly languages?: readonly string[];
|
|
282
298
|
/**
|
|
283
299
|
* True when this final is the flush response to
|
|
284
300
|
* {@link StreamingTranscriber.finalizeUtterance} — i.e. it commits audio
|