@alia.onl/sdk 5.1.0 → 7.1.1

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.
@@ -0,0 +1,560 @@
1
+ import type { PlanStep, ResearchProgress, ResearchSource } from '../types';
2
+
3
+ const MAX_STREAM_BYTES = 8 * 1024 * 1024;
4
+ const MAX_STREAM_LINES = 65_536;
5
+ const MAX_BUFFER_BYTES = 1024 * 1024;
6
+
7
+ type JsonObject = Record<string, unknown>;
8
+
9
+ export type AliaChatStreamEvent =
10
+ | { readonly kind: 'content'; readonly content: string }
11
+ | { readonly kind: 'reasoning'; readonly content: string }
12
+ | {
13
+ readonly kind: 'tool_call';
14
+ readonly toolCallId: string;
15
+ readonly toolName: string;
16
+ readonly args: Record<string, unknown>;
17
+ }
18
+ | {
19
+ readonly kind: 'tool_result';
20
+ readonly toolCallId: string;
21
+ readonly toolName: string;
22
+ readonly output: unknown;
23
+ }
24
+ | { readonly kind: 'research_progress'; readonly progress: ResearchProgress }
25
+ | {
26
+ readonly kind: 'plan_preview';
27
+ readonly planId: string;
28
+ readonly steps: PlanStep[];
29
+ }
30
+ | {
31
+ readonly kind: 'agent_answer';
32
+ readonly content: string;
33
+ readonly agent: { readonly id: string; readonly name: string; readonly avatar: null };
34
+ };
35
+
36
+ export interface AliaChatStreamResult {
37
+ readonly answerReceived: true;
38
+ readonly finishReason: string;
39
+ }
40
+
41
+ export class AliaChatStreamError extends Error {
42
+ constructor(message: string) {
43
+ super(message);
44
+ this.name = 'AliaChatStreamError';
45
+ }
46
+ }
47
+
48
+ function abortError(): Error {
49
+ const error = new Error('The Alia stream was aborted.');
50
+ error.name = 'AbortError';
51
+ return error;
52
+ }
53
+
54
+ function throwIfAborted(signal: AbortSignal | undefined): void {
55
+ if (signal?.aborted) throw abortError();
56
+ }
57
+
58
+ function fail(message: string): never {
59
+ throw new AliaChatStreamError(message);
60
+ }
61
+
62
+ function asObject(value: unknown, context: string): JsonObject {
63
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
64
+ fail(`${context} must be an object.`);
65
+ }
66
+ return value as JsonObject;
67
+ }
68
+
69
+ function assertOnlyKeys(value: JsonObject, allowed: readonly string[], context: string): void {
70
+ const allowedKeys = new Set(allowed);
71
+ const unknown = Object.keys(value).find((key) => !allowedKeys.has(key));
72
+ if (unknown !== undefined) fail(`${context} contains unsupported field ${unknown}.`);
73
+ }
74
+
75
+ function asString(value: unknown, context: string, allowEmpty = false): string {
76
+ if (typeof value !== 'string' || (!allowEmpty && value.trim().length === 0)) {
77
+ fail(`${context} must be a non-empty string.`);
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function asOptionalString(value: unknown, context: string): string | undefined {
83
+ if (value === undefined) return undefined;
84
+ return asString(value, context, true);
85
+ }
86
+
87
+ function asOptionalNumber(value: unknown, context: string): number | undefined {
88
+ if (value === undefined) return undefined;
89
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
90
+ fail(`${context} must be a finite number.`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function asStringArray(value: unknown, context: string): string[] | undefined {
96
+ if (value === undefined) return undefined;
97
+ if (!Array.isArray(value)) fail(`${context} must be an array.`);
98
+ return value.map((entry, index) => asString(entry, `${context}[${index}]`));
99
+ }
100
+
101
+ function requireEventVersion(payload: JsonObject, eventName: string): void {
102
+ if (payload.eventVersion !== 1) {
103
+ fail(`${eventName} has an unsupported eventVersion.`);
104
+ }
105
+ }
106
+
107
+ function parsePlanSteps(value: unknown): PlanStep[] {
108
+ if (!Array.isArray(value) || value.length < 2) {
109
+ fail('alia.plan_preview steps must contain at least two entries.');
110
+ }
111
+
112
+ return value.map((entry, index) => {
113
+ const step = asObject(entry, `alia.plan_preview steps[${index}]`);
114
+ assertOnlyKeys(step, ['action', 'description', 'toolName'], `alia.plan_preview steps[${index}]`);
115
+ return {
116
+ action: asString(step.action, `alia.plan_preview steps[${index}].action`),
117
+ description: asString(step.description, `alia.plan_preview steps[${index}].description`),
118
+ ...(step.toolName === undefined
119
+ ? {}
120
+ : { toolName: asString(step.toolName, `alia.plan_preview steps[${index}].toolName`) }),
121
+ };
122
+ });
123
+ }
124
+
125
+ function parseResearchSources(value: unknown): ResearchSource[] | undefined {
126
+ if (value === undefined) return undefined;
127
+ if (!Array.isArray(value)) fail('alia.research_progress sources must be an array.');
128
+
129
+ return value.map((entry, index) => {
130
+ const source = asObject(entry, `alia.research_progress sources[${index}]`);
131
+ assertOnlyKeys(source, ['id', 'url', 'title'], `alia.research_progress sources[${index}]`);
132
+ if (typeof source.id !== 'number' || !Number.isSafeInteger(source.id)) {
133
+ fail(`alia.research_progress sources[${index}].id must be an integer.`);
134
+ }
135
+ return {
136
+ id: source.id,
137
+ url: asString(source.url, `alia.research_progress sources[${index}].url`),
138
+ title: asString(source.title, `alia.research_progress sources[${index}].title`, true),
139
+ };
140
+ });
141
+ }
142
+
143
+ function parseResearchProgress(payload: JsonObject): ResearchProgress {
144
+ requireEventVersion(payload, 'alia.research_progress');
145
+ assertOnlyKeys(
146
+ payload,
147
+ [
148
+ 'eventVersion',
149
+ 'phase',
150
+ 'message',
151
+ 'subQuestions',
152
+ 'sourcesFound',
153
+ 'currentQuery',
154
+ 'iteration',
155
+ 'sources',
156
+ 'totalSearches',
157
+ ],
158
+ 'alia.research_progress',
159
+ );
160
+ const phase = asString(payload.phase, 'alia.research_progress phase');
161
+ const sources = parseResearchSources(payload.sources);
162
+
163
+ return {
164
+ phase,
165
+ message: asOptionalString(payload.message, 'alia.research_progress message'),
166
+ subQuestions: asStringArray(payload.subQuestions, 'alia.research_progress subQuestions'),
167
+ sourcesFound: asOptionalNumber(payload.sourcesFound, 'alia.research_progress sourcesFound'),
168
+ currentQuery: asOptionalString(payload.currentQuery, 'alia.research_progress currentQuery'),
169
+ iteration: asOptionalNumber(payload.iteration, 'alia.research_progress iteration'),
170
+ isComplete: phase === 'complete',
171
+ sources,
172
+ totalSearches: asOptionalNumber(payload.totalSearches, 'alia.research_progress totalSearches'),
173
+ };
174
+ }
175
+
176
+ function validateIgnoredNamedEvent(eventName: string, payload: JsonObject): void {
177
+ requireEventVersion(payload, eventName);
178
+
179
+ switch (eventName) {
180
+ case 'alia.model_switch':
181
+ assertOnlyKeys(payload, ['eventVersion', 'model', 'modelName'], eventName);
182
+ asString(payload.model, `${eventName} model`);
183
+ asString(payload.modelName, `${eventName} modelName`);
184
+ return;
185
+ case 'alia.suggest_new_conversation':
186
+ assertOnlyKeys(payload, ['eventVersion', 'reason'], eventName);
187
+ asString(payload.reason, `${eventName} reason`);
188
+ return;
189
+ case 'alia.agent_session':
190
+ assertOnlyKeys(payload, ['eventVersion', 'sessionId', 'agentId', 'agentName'], eventName);
191
+ asString(payload.sessionId, `${eventName} sessionId`);
192
+ asString(payload.agentId, `${eventName} agentId`);
193
+ asString(payload.agentName, `${eventName} agentName`);
194
+ return;
195
+ case 'alia.title':
196
+ assertOnlyKeys(payload, ['eventVersion', 'title', 'conversationId'], eventName);
197
+ asString(payload.title, `${eventName} title`);
198
+ asString(payload.conversationId, `${eventName} conversationId`);
199
+ return;
200
+ case 'alia.deprecation':
201
+ assertOnlyKeys(
202
+ payload,
203
+ ['eventVersion', 'identifier', 'replacement', 'deprecatedAt', 'sunsetAt', 'documentation'],
204
+ eventName,
205
+ );
206
+ asString(payload.identifier, `${eventName} identifier`);
207
+ asString(payload.replacement, `${eventName} replacement`);
208
+ asString(payload.deprecatedAt, `${eventName} deprecatedAt`);
209
+ if (payload.sunsetAt !== null) asString(payload.sunsetAt, `${eventName} sunsetAt`);
210
+ asString(payload.documentation, `${eventName} documentation`);
211
+ return;
212
+ case 'alia.route_switch': {
213
+ assertOnlyKeys(payload, ['eventVersion', 'reason', 'scope', 'occurredAt'], eventName);
214
+ const reasons = new Set([
215
+ 'deployment_unavailable',
216
+ 'provider_error',
217
+ 'provider_timeout',
218
+ 'provider_overloaded',
219
+ 'rate_limited',
220
+ 'capacity',
221
+ 'policy_preference',
222
+ ]);
223
+ const scopes = new Set(['same_model_revision', 'same_model', 'cross_model']);
224
+ const reason = asString(payload.reason, `${eventName} reason`);
225
+ const scope = asString(payload.scope, `${eventName} scope`);
226
+ if (!reasons.has(reason) || !scopes.has(scope)) fail(`${eventName} has an unsupported route switch.`);
227
+ asString(payload.occurredAt, `${eventName} occurredAt`);
228
+ return;
229
+ }
230
+ default:
231
+ fail(`Unsupported Alia stream event: ${eventName}.`);
232
+ }
233
+ }
234
+
235
+ function parseNamedEvent(eventName: string, payload: unknown): AliaChatStreamEvent | null {
236
+ const body = asObject(payload, eventName);
237
+ requireEventVersion(body, eventName);
238
+
239
+ switch (eventName) {
240
+ case 'alia.reasoning':
241
+ assertOnlyKeys(body, ['eventVersion', 'content'], eventName);
242
+ return { kind: 'reasoning', content: asString(body.content, `${eventName} content`) };
243
+ case 'alia.tool_result':
244
+ assertOnlyKeys(body, ['eventVersion', 'tool_call_id', 'name', 'output'], eventName);
245
+ if (!Object.hasOwn(body, 'output')) fail(`${eventName} output is required.`);
246
+ return {
247
+ kind: 'tool_result',
248
+ toolCallId: asString(body.tool_call_id, `${eventName} tool_call_id`),
249
+ toolName: asString(body.name, `${eventName} name`),
250
+ output: body.output,
251
+ };
252
+ case 'alia.research_progress':
253
+ return { kind: 'research_progress', progress: parseResearchProgress(body) };
254
+ case 'alia.plan_preview':
255
+ assertOnlyKeys(body, ['eventVersion', 'planId', 'steps'], eventName);
256
+ return {
257
+ kind: 'plan_preview',
258
+ planId: asString(body.planId, `${eventName} planId`),
259
+ steps: parsePlanSteps(body.steps),
260
+ };
261
+ case 'alia.agent': {
262
+ assertOnlyKeys(
263
+ body,
264
+ ['eventVersion', 'agentId', 'agentName', 'agentHandle', 'agentColor', 'content'],
265
+ eventName,
266
+ );
267
+ if (body.agentColor !== null) asString(body.agentColor, `${eventName} agentColor`);
268
+ asString(body.agentHandle, `${eventName} agentHandle`);
269
+ return {
270
+ kind: 'agent_answer',
271
+ content: asString(body.content, `${eventName} content`),
272
+ agent: {
273
+ id: asString(body.agentId, `${eventName} agentId`),
274
+ name: asString(body.agentName, `${eventName} agentName`),
275
+ avatar: null,
276
+ },
277
+ };
278
+ }
279
+ default:
280
+ validateIgnoredNamedEvent(eventName, body);
281
+ return null;
282
+ }
283
+ }
284
+
285
+ function parseToolCalls(value: unknown): AliaChatStreamEvent[] {
286
+ if (!Array.isArray(value) || value.length === 0) {
287
+ fail('OpenAI tool_calls must be a non-empty array.');
288
+ }
289
+
290
+ return value.map((entry, index) => {
291
+ const call = asObject(entry, `tool_calls[${index}]`);
292
+ assertOnlyKeys(call, ['index', 'id', 'type', 'function'], `tool_calls[${index}]`);
293
+ if (typeof call.index !== 'number' || !Number.isSafeInteger(call.index)) {
294
+ fail(`tool_calls[${index}].index must be an integer.`);
295
+ }
296
+ if (call.type !== 'function') fail(`tool_calls[${index}].type must be function.`);
297
+ const fn = asObject(call.function, `tool_calls[${index}].function`);
298
+ assertOnlyKeys(fn, ['name', 'arguments'], `tool_calls[${index}].function`);
299
+ const encodedArgs = asString(fn.arguments, `tool_calls[${index}].function.arguments`, true);
300
+ let decodedArgs: unknown;
301
+ try {
302
+ decodedArgs = JSON.parse(encodedArgs);
303
+ } catch {
304
+ fail(`tool_calls[${index}].function.arguments is not valid JSON.`);
305
+ }
306
+
307
+ return {
308
+ kind: 'tool_call' as const,
309
+ toolCallId: asString(call.id, `tool_calls[${index}].id`),
310
+ toolName: asString(fn.name, `tool_calls[${index}].function.name`),
311
+ args: asObject(decodedArgs, `tool_calls[${index}].function.arguments`),
312
+ };
313
+ });
314
+ }
315
+
316
+ interface ParsedOpenAIFrame {
317
+ readonly events: AliaChatStreamEvent[];
318
+ readonly finishReason: string | null;
319
+ }
320
+
321
+ function parseOpenAIFrame(payload: unknown): ParsedOpenAIFrame {
322
+ const body = asObject(payload, 'OpenAI stream frame');
323
+ if (body.error !== undefined) {
324
+ const error = asObject(body.error, 'OpenAI stream error');
325
+ asString(error.message, 'OpenAI stream error message');
326
+ fail('Alia ended the stream with an error.');
327
+ }
328
+ assertOnlyKeys(
329
+ body,
330
+ [
331
+ 'id',
332
+ 'object',
333
+ 'created',
334
+ 'model',
335
+ 'system_fingerprint',
336
+ 'service_tier',
337
+ 'choices',
338
+ 'usage',
339
+ 'alia_usage',
340
+ 'alia_meta',
341
+ ],
342
+ 'OpenAI stream frame',
343
+ );
344
+ if (body.object !== 'chat.completion.chunk') {
345
+ fail('Unexpected object in the Alia stream.');
346
+ }
347
+ asString(body.id, 'OpenAI stream frame id');
348
+ asString(body.model, 'OpenAI stream frame model');
349
+ if (typeof body.created !== 'number' || !Number.isSafeInteger(body.created)) {
350
+ fail('OpenAI stream frame created must be an integer.');
351
+ }
352
+ if (!Array.isArray(body.choices)) fail('OpenAI stream frame choices must be an array.');
353
+
354
+ if (body.choices.length === 0) {
355
+ asObject(body.usage, 'OpenAI usage frame usage');
356
+ return { events: [], finishReason: null };
357
+ }
358
+ if (body.choices.length !== 1) fail('OpenAI stream frame must contain exactly one choice.');
359
+
360
+ const events: AliaChatStreamEvent[] = [];
361
+ let finishReason: string | null = null;
362
+ for (const [index, rawChoice] of body.choices.entries()) {
363
+ const choice = asObject(rawChoice, `choices[${index}]`);
364
+ assertOnlyKeys(choice, ['index', 'delta', 'finish_reason', 'logprobs'], `choices[${index}]`);
365
+ if (typeof choice.index !== 'number' || !Number.isSafeInteger(choice.index)) {
366
+ fail(`choices[${index}].index must be an integer.`);
367
+ }
368
+ if (choice.index !== 0) fail(`choices[${index}].index must be zero.`);
369
+ const delta = asObject(choice.delta, `choices[${index}].delta`);
370
+ const allowedDeltaKeys = new Set(['content', 'reasoning', 'role', 'tool_calls']);
371
+ for (const key of Object.keys(delta)) {
372
+ if (!allowedDeltaKeys.has(key)) fail(`Unsupported OpenAI delta field: ${key}.`);
373
+ }
374
+ if (delta.role !== undefined && delta.role !== 'assistant') {
375
+ fail(`choices[${index}].delta.role must be assistant.`);
376
+ }
377
+ if (delta.content !== undefined) {
378
+ events.push({ kind: 'content', content: asString(delta.content, `choices[${index}].delta.content`, true) });
379
+ }
380
+ if (delta.reasoning !== undefined) {
381
+ events.push({ kind: 'reasoning', content: asString(delta.reasoning, `choices[${index}].delta.reasoning`) });
382
+ }
383
+ if (delta.tool_calls !== undefined) events.push(...parseToolCalls(delta.tool_calls));
384
+
385
+ if (choice.finish_reason !== null) {
386
+ const current = asString(choice.finish_reason, `choices[${index}].finish_reason`);
387
+ if (finishReason !== null && finishReason !== current) {
388
+ fail('OpenAI stream frame contains conflicting finish reasons.');
389
+ }
390
+ finishReason = current;
391
+ }
392
+ }
393
+
394
+ return { events, finishReason };
395
+ }
396
+
397
+ interface SSEFrame {
398
+ eventName: string;
399
+ data: string;
400
+ }
401
+
402
+ function parseSSEFrame(rawFrame: string): SSEFrame | null {
403
+ const lines = rawFrame.split('\n');
404
+ let eventName = '';
405
+ let data: string | null = null;
406
+
407
+ for (const rawLine of lines) {
408
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
409
+ if (line === '' || line.startsWith(':')) continue;
410
+ if (line.startsWith('event:')) {
411
+ if (eventName !== '') fail('An SSE frame contains more than one event field.');
412
+ eventName = line.slice(6).trimStart();
413
+ if (eventName.trim().length === 0) fail('An SSE event name cannot be empty.');
414
+ continue;
415
+ }
416
+ if (line.startsWith('data:')) {
417
+ if (data !== null) fail('An SSE frame contains more than one data field.');
418
+ data = line.slice(5).trimStart();
419
+ continue;
420
+ }
421
+ fail('The Alia response contains an unsupported SSE field.');
422
+ }
423
+
424
+ if (data === null) {
425
+ if (eventName !== '') fail('A named SSE event is missing its data field.');
426
+ return null;
427
+ }
428
+ return { eventName, data };
429
+ }
430
+
431
+ function splitNextFrame(buffer: string): { frame: string; rest: string } | null {
432
+ const lf = buffer.indexOf('\n\n');
433
+ const crlf = buffer.indexOf('\r\n\r\n');
434
+ if (lf < 0 && crlf < 0) return null;
435
+ if (crlf >= 0 && (lf < 0 || crlf < lf)) {
436
+ return { frame: buffer.slice(0, crlf), rest: buffer.slice(crlf + 4) };
437
+ }
438
+ return { frame: buffer.slice(0, lf), rest: buffer.slice(lf + 2) };
439
+ }
440
+
441
+ /**
442
+ * Consume the exact Alia product stream carried on the compatibility endpoint.
443
+ * A successful result means an answer, a finish chunk and the terminal [DONE]
444
+ * sentinel were all observed. Anything less is a failed turn.
445
+ */
446
+ export async function consumeAliaChatStream(
447
+ response: Response,
448
+ onEvent: (event: AliaChatStreamEvent) => void,
449
+ signal?: AbortSignal,
450
+ ): Promise<AliaChatStreamResult> {
451
+ if (signal?.aborted) {
452
+ await response.body?.cancel().catch(() => undefined);
453
+ throw abortError();
454
+ }
455
+ const mime = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
456
+ if (mime !== 'text/event-stream') {
457
+ await response.body?.cancel().catch(() => undefined);
458
+ fail('Alia returned a non-streaming response.');
459
+ }
460
+ if (!response.body || typeof response.body.getReader !== 'function') {
461
+ fail('Alia returned no readable stream.');
462
+ }
463
+
464
+ const reader = response.body.getReader();
465
+ const onAbort = (): void => {
466
+ void reader.cancel().catch(() => undefined);
467
+ };
468
+ signal?.addEventListener('abort', onAbort, { once: true });
469
+ const decoder = new TextDecoder('utf-8', { fatal: true });
470
+ let buffer = '';
471
+ let totalBytes = 0;
472
+ let lineCount = 0;
473
+ let answerReceived = false;
474
+ let finishReason: string | null = null;
475
+ let doneReceived = false;
476
+
477
+ const dispatch = (frame: SSEFrame): void => {
478
+ if (doneReceived) fail('The Alia stream continued after [DONE].');
479
+ if (frame.data === '[DONE]') {
480
+ if (frame.eventName !== '') fail('[DONE] must be an unnamed SSE data frame.');
481
+ if (finishReason === null) fail('The Alia stream ended without a finish chunk.');
482
+ if (!answerReceived) fail('Alia completed without an assistant answer.');
483
+ doneReceived = true;
484
+ return;
485
+ }
486
+
487
+ let payload: unknown;
488
+ try {
489
+ payload = JSON.parse(frame.data);
490
+ } catch {
491
+ fail('The Alia stream contains malformed JSON.');
492
+ }
493
+
494
+ if (frame.eventName !== '') {
495
+ const event = parseNamedEvent(frame.eventName, payload);
496
+ if (event !== null) {
497
+ if (event.kind === 'agent_answer') answerReceived = true;
498
+ onEvent(event);
499
+ }
500
+ return;
501
+ }
502
+
503
+ const parsed = parseOpenAIFrame(payload);
504
+ if (finishReason !== null && parsed.events.length > 0) {
505
+ fail('The Alia stream emitted content after its finish chunk.');
506
+ }
507
+ for (const event of parsed.events) {
508
+ if (event.kind === 'content' && event.content.trim().length > 0) answerReceived = true;
509
+ onEvent(event);
510
+ }
511
+ if (parsed.finishReason !== null) {
512
+ if (finishReason !== null) fail('The Alia stream contains more than one finish chunk.');
513
+ finishReason = parsed.finishReason;
514
+ }
515
+ };
516
+
517
+ try {
518
+ while (true) {
519
+ throwIfAborted(signal);
520
+ const chunk = await reader.read();
521
+ throwIfAborted(signal);
522
+ if (chunk.done) {
523
+ buffer += decoder.decode();
524
+ break;
525
+ }
526
+ totalBytes += chunk.value.byteLength;
527
+ if (totalBytes > MAX_STREAM_BYTES) fail('The Alia stream exceeded its byte limit.');
528
+ buffer += decoder.decode(chunk.value, { stream: true });
529
+
530
+ let next = splitNextFrame(buffer);
531
+ while (next !== null) {
532
+ throwIfAborted(signal);
533
+ buffer = next.rest;
534
+ lineCount += next.frame.split('\n').length;
535
+ if (lineCount > MAX_STREAM_LINES) fail('The Alia stream exceeded its line limit.');
536
+ const frame = parseSSEFrame(next.frame);
537
+ if (frame !== null) dispatch(frame);
538
+ next = splitNextFrame(buffer);
539
+ }
540
+ if (buffer.length > MAX_BUFFER_BYTES) fail('The Alia stream contains an oversized frame.');
541
+ if (doneReceived) {
542
+ if (buffer.trim().length > 0) fail('The Alia stream continued after [DONE].');
543
+ await reader.cancel().catch(() => undefined);
544
+ break;
545
+ }
546
+ }
547
+
548
+ if (buffer.trim().length > 0) fail('The Alia stream ended inside an SSE frame.');
549
+ if (!doneReceived || finishReason === null || !answerReceived) {
550
+ fail('The Alia stream ended before completing an assistant answer.');
551
+ }
552
+ return { answerReceived: true, finishReason };
553
+ } catch (error: unknown) {
554
+ await reader.cancel().catch(() => undefined);
555
+ throw error;
556
+ } finally {
557
+ signal?.removeEventListener('abort', onAbort);
558
+ reader.releaseLock();
559
+ }
560
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ consumeAliaChatStream,
3
+ type AliaChatStreamEvent,
4
+ type AliaChatStreamResult,
5
+ } from './chat-stream';
6
+
7
+ export interface AuthenticatedResponseClient {
8
+ requestAuthenticatedResponse(config: {
9
+ method: 'POST';
10
+ url: string;
11
+ body: string;
12
+ headers: Record<string, string>;
13
+ signal: AbortSignal;
14
+ }): Promise<Response>;
15
+ }
16
+
17
+ export interface AliaChatRequest {
18
+ readonly url: string;
19
+ readonly model: string;
20
+ readonly messages: ReadonlyArray<{ readonly role: string; readonly content: string }>;
21
+ }
22
+
23
+ /**
24
+ * The authenticated transport boundary for an SDK chat turn. Authentication,
25
+ * preflight refresh and the single supported 401 replay belong to the linked
26
+ * Oxy client; this layer never reads or writes bearer tokens itself.
27
+ */
28
+ export async function streamAliaChat(
29
+ client: AuthenticatedResponseClient,
30
+ request: AliaChatRequest,
31
+ signal: AbortSignal,
32
+ onEvent: (event: AliaChatStreamEvent) => void,
33
+ ): Promise<AliaChatStreamResult> {
34
+ const response = await client.requestAuthenticatedResponse({
35
+ method: 'POST',
36
+ url: request.url,
37
+ headers: {
38
+ Accept: 'text/event-stream',
39
+ 'Content-Type': 'application/json',
40
+ },
41
+ body: JSON.stringify({
42
+ model: request.model,
43
+ messages: request.messages,
44
+ stream: true,
45
+ }),
46
+ signal,
47
+ });
48
+
49
+ if (!response.ok) {
50
+ await response.body?.cancel().catch(() => undefined);
51
+ throw new Error(`Alia request failed with status ${response.status}.`);
52
+ }
53
+
54
+ return consumeAliaChatStream(response, onEvent, signal);
55
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Build-time preferences for the SDK.
3
+ *
4
+ * These mirror `packages/app/lib/config.ts`, and carry the same warning it does:
5
+ * **a value here is never trusted.** The catalogue carries no default of its own
6
+ * — it orders entries by price and says explicitly that position is not a
7
+ * recommendation — so a build-time value is the only mechanism available for
8
+ * "what should this ask for first". `resolveSelection` then checks it against
9
+ * what the server actually offers and falls through when the catalogue does not
10
+ * list it.
11
+ *
12
+ * Overridable per build so a consumer is not stranded on a retired identifier
13
+ * until the SDK ships a new version, which is the failure the hardcoded
14
+ * `'alia-v1'` inside `useAliaChat` used to guarantee.
15
+ */
16
+
17
+ /** What a chat request asks for when the caller names no model. Checked against the catalogue. */
18
+ export const PREFERRED_CHAT_MODEL_ID =
19
+ process.env.EXPO_PUBLIC_ALIA_DEFAULT_MODEL ?? 'profile:v1';
20
+
21
+ /**
22
+ * What speech synthesis and the voice session ask for.
23
+ *
24
+ * **Not resolved against the chat catalogue, and that is deliberate.**
25
+ * `GET /catalogue` describes what a chat PICKER may offer, and
26
+ * `resolveSelection` filters to `chat_visible` entries; a voice identifier is
27
+ * not one of those. Passing this through the chat resolver would replace it with
28
+ * a chat model the moment the catalogue did not list it, and `/v1/audio/speech`
29
+ * would then be asked to synthesize with something that cannot speak — a
30
+ * plausible-looking substitution that fails at the far end, which is worse than
31
+ * the 400 it replaces.
32
+ *
33
+ * So this stays a preference the consumer can override and the server validates.
34
+ * Resolving it properly needs a catalogue that describes voice capability as a
35
+ * first-class filter; that is workstream 5's business, not a thing to fake here.
36
+ */
37
+ export const PREFERRED_VOICE_MODEL_ID =
38
+ process.env.EXPO_PUBLIC_ALIA_VOICE_MODEL ?? 'profile:v1-voice';
@@ -16,7 +16,7 @@ const TOOL_REGISTRY: Record<string, ToolDefinition> = {
16
16
  sendWhatsAppMessage: { label: 'Sending WhatsApp message', category: 'communication' },
17
17
  getWhatsAppChats: { label: 'Loading WhatsApp chats', category: 'communication' },
18
18
  getWhatsAppMessages: { label: 'Reading WhatsApp messages', category: 'communication' },
19
- sendTelegram: { label: 'Sending Telegram message', category: 'communication' },
19
+ sendTelegramMessage: { label: 'Sending Telegram message', category: 'communication' },
20
20
 
21
21
  // Utility
22
22
  getCurrentDate: { label: 'Getting current date', category: 'utility' },
package/tsconfig.json CHANGED
@@ -10,5 +10,5 @@
10
10
  "declaration": true,
11
11
  "outDir": "lib"
12
12
  },
13
- "include": ["src"]
13
+ "include": ["src", "tests"]
14
14
  }