@oberik/sdk 0.60.0 → 0.62.0

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/dist/cjs/index.js CHANGED
@@ -2096,6 +2096,208 @@ class AgentFramework {
2096
2096
  }),
2097
2097
  },
2098
2098
  };
2099
+ // -- voice ---------------------------------------------------------------
2100
+ /**
2101
+ * Live voice calls.
2102
+ *
2103
+ * `open()` creates the call and connects the audio socket; everything after that is
2104
+ * `send()` for microphone audio, `onAudio` for what to play, and events for what is
2105
+ * happening. The token is the same end-user JWT everything else uses, and the turns
2106
+ * behind the call are ordinary turns — same capability gate, same tool allow-list, same
2107
+ * per-customer cost attribution.
2108
+ *
2109
+ * Needs a `WebSocket` global: present in browsers and in Node 22+. On older Node, pass
2110
+ * one in as `webSocket` — the SDK deliberately has no dependencies, so it will not bring
2111
+ * an implementation of its own.
2112
+ */
2113
+ voice = {
2114
+ open: async (body = {}, handlers = {}, opts = {}) => {
2115
+ const info = await this.request("POST", "/voice/sessions", { body });
2116
+ return this.connectVoice(info, handlers, opts.webSocket);
2117
+ },
2118
+ /** Reconnect to a call that is already open — a page reload, a second screen watching.
2119
+ *
2120
+ * Audio goes to whoever is connected; events go to everyone. So a supervisor can watch
2121
+ * a call without taking it over, which is what a call-centre floor actually needs. */
2122
+ attach: async (id, handlers = {}, opts = {}) => {
2123
+ const info = await this.request("GET", `/voice/sessions/${id}`);
2124
+ return this.connectVoice(info, handlers, opts.webSocket);
2125
+ },
2126
+ /** Every call this token can see. */
2127
+ list: () => this.request("GET", "/voice/sessions"),
2128
+ /** What the frontend can do — the voices and roles a project may ask for. Read it rather
2129
+ * than hard-coding a list: asking for a voice the host does not have fails the call at
2130
+ * `open()`, which is a worse place to find out than a dropdown. */
2131
+ frontend: () => this.request("GET", "/voice/frontend"),
2132
+ };
2133
+ connectVoice(info, handlers, webSocketImpl) {
2134
+ const WS = (webSocketImpl ?? globalThis.WebSocket);
2135
+ if (!WS) {
2136
+ throw new AgentStreamError("no WebSocket available. Browsers and Node 22+ have one; on older Node pass " +
2137
+ "`{ webSocket: WebSocket }` from a library of your choice. The SDK has no " +
2138
+ "dependencies and will not choose one for you.");
2139
+ }
2140
+ const base = this.baseUrl.replace(/^http/, "ws");
2141
+ const url = info.stream_url.startsWith("ws")
2142
+ ? info.stream_url
2143
+ : `${base}${info.stream_url}`;
2144
+ const ws = new WS(url);
2145
+ ws.binaryType = "arraybuffer";
2146
+ const listeners = new Map();
2147
+ // Audio the caller produced before the socket finished opening. Buffered rather than
2148
+ // dropped: a browser's microphone starts before the connection does, and dropping the
2149
+ // first quarter-second means the caller's first word is missing — which reads as the
2150
+ // agent not listening.
2151
+ const pending = [];
2152
+ let closed = false;
2153
+ ws.onopen = () => {
2154
+ // The token goes in the first frame rather than in the URL. A query-string token ends
2155
+ // up in proxy logs and in browser history, and a WebSocket cannot carry an
2156
+ // Authorization header from a browser.
2157
+ void (async () => {
2158
+ const headers = await this.authHeaders();
2159
+ const bearer = headers["Authorization"] ?? headers["X-API-Key"] ?? "";
2160
+ ws.send(JSON.stringify({ type: "auth", token: bearer.replace(/^Bearer /, "") }));
2161
+ for (const chunk of pending)
2162
+ ws.send(chunk);
2163
+ pending.length = 0;
2164
+ })();
2165
+ };
2166
+ ws.onmessage = (e) => {
2167
+ if (typeof e.data !== "string") {
2168
+ handlers.onAudio?.(e.data);
2169
+ return;
2170
+ }
2171
+ let frame;
2172
+ try {
2173
+ frame = JSON.parse(e.data);
2174
+ }
2175
+ catch {
2176
+ return;
2177
+ }
2178
+ handlers.onEvent?.(frame);
2179
+ if (frame.event === "state")
2180
+ handlers.onState?.(frame.data);
2181
+ if (frame.event === "error")
2182
+ handlers.onError?.(frame.data.detail);
2183
+ for (const fn of listeners.get(frame.event) ?? [])
2184
+ fn(frame.data);
2185
+ };
2186
+ ws.onerror = () => handlers.onError?.("the voice socket failed");
2187
+ ws.onclose = (e) => {
2188
+ closed = true;
2189
+ handlers.onClose?.(e?.reason ?? "closed");
2190
+ };
2191
+ const post = async (path, body) => {
2192
+ await this.request("POST", `/voice/sessions/${info.id}${path}`, { body: body ?? {} });
2193
+ };
2194
+ /** The same call, keeping the answer. For the routes whose answer is the point — a
2195
+ * disclosure the server may have refused to say, and the reason it did. */
2196
+ const postFor = (path, body) => this.request("POST", `/voice/sessions/${info.id}${path}`, { body: body ?? {} });
2197
+ /** A control frame down the socket rather than an HTTP call.
2198
+ *
2199
+ * Because these are about *timing*: "the caller started talking" has to reach the server
2200
+ * in the same tens of milliseconds the audio does, and an HTTP round trip on the barge-in
2201
+ * path is the difference between the agent stopping and the agent talking over somebody.
2202
+ * Hold and transfer go over HTTP precisely because they are not on that path. */
2203
+ const control = (msg) => {
2204
+ if (ws.readyState === 1)
2205
+ ws.send(JSON.stringify(msg));
2206
+ };
2207
+ return {
2208
+ id: info.id,
2209
+ sessionId: info.session_id,
2210
+ rate: info.rate,
2211
+ info,
2212
+ get closed() {
2213
+ return closed;
2214
+ },
2215
+ send: (pcm) => {
2216
+ const bytes = pcm instanceof ArrayBuffer
2217
+ ? new Uint8Array(pcm)
2218
+ : new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
2219
+ // 1 === OPEN. Compared numerically because a polyfilled WebSocket may not expose
2220
+ // the static constants, and `ws.OPEN` on an instance is not in the spec.
2221
+ if (ws.readyState === 1)
2222
+ ws.send(bytes);
2223
+ else if (!closed)
2224
+ pending.push(bytes);
2225
+ },
2226
+ on: (name, fn) => {
2227
+ const set = listeners.get(name) ?? new Set();
2228
+ listeners.set(name, set);
2229
+ set.add(fn);
2230
+ return () => set.delete(fn);
2231
+ },
2232
+ snapshot: () => this.request("GET", `/voice/sessions/${info.id}/state`),
2233
+ hold: (o = {}) => post("/hold", o),
2234
+ resume: (o = {}) => post("/resume", o),
2235
+ holdUntil: async (work, o = {}) => {
2236
+ // The hold goes on FIRST, and the work starts after. The other order leaves a window
2237
+ // where the work is running and the caller is hearing nothing — short for a fast
2238
+ // lookup, and exactly as long as the request takes for a slow one, which is the case
2239
+ // this whole mechanism is for.
2240
+ const maxSeconds = o.maxSeconds ?? 120;
2241
+ await post("/hold", {
2242
+ reason: o.reason ?? "the app is waiting on something",
2243
+ listen: o.listen,
2244
+ max_seconds: maxSeconds,
2245
+ });
2246
+ // The caller ending the hold themselves. Watched rather than polled, so "the caller
2247
+ // said never mind" and "the CRM answered" are a genuine race.
2248
+ let onInterrupted;
2249
+ const interrupted = new Promise((resolve) => {
2250
+ onInterrupted = () => resolve("interrupted");
2251
+ });
2252
+ const offHold = listeners.get("hold.interrupted") ?? new Set();
2253
+ listeners.set("hold.interrupted", offHold);
2254
+ const watcher = () => onInterrupted?.();
2255
+ offHold.add(watcher);
2256
+ let timer;
2257
+ const expired = new Promise((resolve) => {
2258
+ timer = setTimeout(() => resolve("timeout"), maxSeconds * 1000);
2259
+ });
2260
+ try {
2261
+ const raced = await Promise.race([
2262
+ work().then((result) => ({ outcome: "done", result }), (error) => ({ outcome: "failed", error })),
2263
+ interrupted.then(() => ({ outcome: "interrupted" })),
2264
+ expired.then(() => ({ outcome: "timeout" })),
2265
+ ]);
2266
+ // Only resume for the outcomes where the caller is still waiting on us. An
2267
+ // interruption already took them off hold — resuming again would say "thanks for
2268
+ // waiting" to somebody who just said never mind.
2269
+ if (raced.outcome !== "interrupted") {
2270
+ await post("/resume", { lead: o.lead });
2271
+ }
2272
+ return raced;
2273
+ }
2274
+ finally {
2275
+ if (timer !== undefined)
2276
+ clearTimeout(timer);
2277
+ offHold.delete(watcher);
2278
+ }
2279
+ },
2280
+ transfer: (to, context) => post("/transfer", { to, context }),
2281
+ inject: (facts) => post("/context", { facts }),
2282
+ say: (text, o = {}) => postFor("/speech", { text, interrupt: o.interrupt ?? true }),
2283
+ speechStart: () => control({ type: "speech_start" }),
2284
+ speechEnd: () => control({ type: "speech_end" }),
2285
+ transcript: (text, final = true) => control({ type: "transcript", text, final }),
2286
+ dtmf: (digit) => control({ type: "dtmf", digit }),
2287
+ hangUp: () => control({ type: "hangup" }),
2288
+ markPlayed: (mark) => control({ type: "mark", mark }),
2289
+ bench: () => this.request("GET", `/voice/sessions/${info.id}/bench`),
2290
+ close: () => {
2291
+ closed = true;
2292
+ try {
2293
+ ws.close();
2294
+ }
2295
+ catch {
2296
+ /* already gone */
2297
+ }
2298
+ },
2299
+ };
2300
+ }
2099
2301
  // -- documents -----------------------------------------------------------
2100
2302
  /** Agent Plugins — the skills this token can reach for, and the ones it may add.
2101
2303
  *
@@ -3176,6 +3378,14 @@ class OberikProject {
3176
3378
  * `set(await get())` — the obvious way to flip one flag — works. Anything else
3177
3379
  * unrecognised still errors: a capability nobody enforces must not be accepted in
3178
3380
  * silence, which is the failure this whole check exists for.
3381
+ *
3382
+ * That now includes the MEMBERS of the two modality lists, which take `image`,
3383
+ * `audio`, `video` or `file` and refuse anything else by name. They used to be
3384
+ * filtered — so `outputModalities: ["banana"]` and `outputModalities: ["image"]` on a
3385
+ * text-only project were the same 200 storing `[]`, and a token that may produce
3386
+ * nothing looks exactly like one that was saved correctly. A real modality no
3387
+ * registered model supports is still dropped rather than granted, but the response
3388
+ * says so in `warnings`.
3179
3389
  */
3180
3390
  set: (caps) => {
3181
3391
  const { supportedModalities: _readOnly, ...settable } = caps;
@@ -3300,6 +3510,11 @@ class OberikProject {
3300
3510
  /** Name a chat model AND an embedding model: the first lets the agent answer, the
3301
3511
  * second lets it index. The response's `derived` says what was set for you.
3302
3512
  *
3513
+ * `values` must carry every field the provider's catalog entry declares without
3514
+ * `optional` — `api_base` for a self-hosted server, `api_key` for a hosted API — and a
3515
+ * missing one is refused, naming it. A vLLM provider with a key and no base URL used
3516
+ * to register happily and could not be reached by anything.
3517
+ *
3303
3518
  * `create`, like every other collection here. It was `add`, and `create` is what a
3304
3519
  * developer guesses — a reporter wrote `providers.create` from memory, it threw
3305
3520
  * mid-run, and they filed it against themselves (OBE-106). Two collections used