@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,209 @@
1
+ /**
2
+ * The model catalogue, as the SDK consumes it (`GET /catalogue`, epic #139
3
+ * workstream 5).
4
+ *
5
+ * Every surface used to hardcode an `alia-*` identifier as its default —
6
+ * `useAliaChat` sent `alia-v1`, `useTTS` and `useVoiceRoom` sent `alia-v1-voice`
7
+ * — which meant a retired identifier became a 400 in a consumer's app with
8
+ * nothing the consumer could do about it, since the string was baked into the
9
+ * package they installed. This module is how the SDK asks the server what it
10
+ * offers instead.
11
+ *
12
+ * ## Deliberately not React, and deliberately not react-query
13
+ *
14
+ * `packages/app` reads the same surface through `useCatalogue`, a react-query
15
+ * hook. The SDK cannot copy that: it declares `@tanstack/react-query` as a
16
+ * dependency but imports it in **zero** modules today — measured — so a consumer
17
+ * mounting `<AliaChatSheet>` is not required to have a `QueryClientProvider`
18
+ * anywhere above it. Introducing one here would turn "the model default came
19
+ * from the server" into a breaking change for every consumer, which is a bad
20
+ * trade for a fallback.
21
+ *
22
+ * Resolution therefore happens where it matters — inside `send`, at the moment a
23
+ * request is about to name a model — rather than at render. A module-level cache
24
+ * keeps that from becoming a fetch per message.
25
+ *
26
+ * ## The parsing rules are the app's, because the contract is the same one
27
+ *
28
+ * Two rules, both about not inventing (see `packages/app/lib/hooks/use-catalogue.ts`,
29
+ * which states them at length):
30
+ *
31
+ * - An entry whose `object` is neither known value is DROPPED. Defaulting it to
32
+ * either kind would break ADR 0003 invariant 1 in one direction or the other.
33
+ * - A response whose entries all fail to parse THROWS rather than reading as an
34
+ * empty catalogue, because "no models" and "we could not read the models"
35
+ * look identical to every caller below and one of them means "offer nothing".
36
+ *
37
+ * This file is a second implementation of that contract rather than a shared
38
+ * one, and that is forced rather than chosen: `@alia.onl/sdk` ships as raw
39
+ * source, so it cannot depend on an unpublished workspace package — a consumer's
40
+ * Metro would fail to resolve it. The three Node-side clients (Codea, Cowork,
41
+ * the CLI) share one instead.
42
+ */
43
+
44
+ /** A product-owned policy that picks a model per request, or a reference to one named model. */
45
+ export type CatalogueEntryKind = 'routing_profile' | 'model';
46
+
47
+ export interface CatalogueEntry {
48
+ readonly id: string;
49
+ readonly kind: CatalogueEntryKind;
50
+ readonly displayName: string;
51
+ readonly description: string;
52
+ /** Product policy: whether a chat picker surfaces this entry. */
53
+ readonly chatVisible: boolean;
54
+ /** The server states this as a claim; an entry that omits it is not called unavailable. */
55
+ readonly unavailable: boolean;
56
+ }
57
+
58
+ type JsonObject = Record<string, unknown>;
59
+
60
+ function asObject(value: unknown): JsonObject | null {
61
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
62
+ ? (value as JsonObject)
63
+ : null;
64
+ }
65
+
66
+ function asText(value: unknown): string | null {
67
+ return typeof value === 'string' ? value : null;
68
+ }
69
+
70
+ function parseEntry(value: unknown): CatalogueEntry | null {
71
+ const raw = asObject(value);
72
+ if (raw === null) return null;
73
+
74
+ const id = asText(raw.id);
75
+ const displayName = asText(raw.display_name);
76
+ if (id === null || displayName === null) return null;
77
+
78
+ if (raw.object !== 'model' && raw.object !== 'routing_profile') return null;
79
+ const availability = asObject(raw.availability) ?? {};
80
+
81
+ return {
82
+ id,
83
+ kind: raw.object,
84
+ displayName,
85
+ description: asText(raw.description) ?? '',
86
+ chatVisible: raw.chat_visible === true,
87
+ unavailable: availability.status === 'unavailable',
88
+ };
89
+ }
90
+
91
+ /** Turn a catalogue response into entries, or throw. */
92
+ export function parseCatalogue(payload: unknown): CatalogueEntry[] {
93
+ const body = asObject(payload);
94
+ const data = body === null ? null : body.data;
95
+ if (!Array.isArray(data)) throw new Error('The model catalogue response could not be read.');
96
+
97
+ const entries: CatalogueEntry[] = [];
98
+ for (const value of data) {
99
+ const entry = parseEntry(value);
100
+ if (entry !== null) entries.push(entry);
101
+ }
102
+ if (data.length > 0 && entries.length === 0) {
103
+ throw new Error('The model catalogue response could not be read.');
104
+ }
105
+ return entries;
106
+ }
107
+
108
+ export interface ModelSelection {
109
+ /** What the caller asked for, which is what a picker keeps showing as chosen. */
110
+ readonly requestedId: string;
111
+ /** What a request should carry. Differs from `requestedId` only when replaced. */
112
+ readonly effectiveId: string;
113
+ /** `replaced` when the requested identifier is not one the catalogue offers. */
114
+ readonly source: 'requested' | 'replaced';
115
+ }
116
+
117
+ /**
118
+ * Resolve a requested identifier against the catalogue.
119
+ *
120
+ * The same three deliberate non-behaviours as the app's `resolveSelection`:
121
+ *
122
+ * - **No catalogue — not loaded, or the request failed — leaves the choice
123
+ * alone.** Replacing on missing data changes the model under the user on any
124
+ * slow cold start, which is the same wrong answer as a retirement.
125
+ * - **An entry reported UNAVAILABLE is still honoured.** That is a health
126
+ * signal about the models behind an entry and the server already falls back
127
+ * among them; picking a different one is a product decision nobody made.
128
+ * - **`preferredId` is never trusted.** It is a build-time value, so it is
129
+ * checked against the catalogue like any other and falls through when the
130
+ * catalogue does not offer it.
131
+ */
132
+ export function resolveSelection(
133
+ requestedId: string,
134
+ entries: readonly CatalogueEntry[] | undefined,
135
+ preferredId?: string,
136
+ ): ModelSelection {
137
+ if (entries === undefined) {
138
+ return { requestedId, effectiveId: requestedId, source: 'requested' };
139
+ }
140
+
141
+ const offered = entries.filter((entry) => entry.chatVisible);
142
+ if (offered.some((entry) => entry.id === requestedId)) {
143
+ return { requestedId, effectiveId: requestedId, source: 'requested' };
144
+ }
145
+
146
+ const replacement =
147
+ (preferredId === undefined
148
+ ? undefined
149
+ : offered.find((entry) => entry.id === preferredId)) ??
150
+ offered.find((entry) => !entry.unavailable) ??
151
+ offered[0];
152
+ if (replacement === undefined) {
153
+ return { requestedId, effectiveId: requestedId, source: 'requested' };
154
+ }
155
+ return { requestedId, effectiveId: replacement.id, source: 'replaced' };
156
+ }
157
+
158
+ /**
159
+ * The catalogue for one API base URL, fetched at most once per URL.
160
+ *
161
+ * The promise is cached rather than the result, so concurrent callers share one
162
+ * request. A REJECTED promise is evicted, because caching a failure would make a
163
+ * single cold-start network blip permanent for the life of the process.
164
+ */
165
+ const inFlight = new Map<string, Promise<CatalogueEntry[]>>();
166
+
167
+ export function fetchCatalogue(apiUrl: string, accessToken?: string): Promise<CatalogueEntry[]> {
168
+ const cached = inFlight.get(apiUrl);
169
+ if (cached !== undefined) return cached;
170
+
171
+ const request = (async () => {
172
+ const response = await fetch(`${apiUrl}/catalogue`, {
173
+ headers: accessToken === undefined ? {} : { Authorization: `Bearer ${accessToken}` },
174
+ });
175
+ if (!response.ok) throw new Error(`The model catalogue request failed (${response.status}).`);
176
+ return parseCatalogue(await response.json());
177
+ })();
178
+
179
+ inFlight.set(apiUrl, request);
180
+ request.catch(() => inFlight.delete(apiUrl));
181
+ return request;
182
+ }
183
+
184
+ /** Drop the cache. Exported for consumers that switch API base URL or sign a different user in. */
185
+ export function clearCatalogueCache(): void {
186
+ inFlight.clear();
187
+ }
188
+
189
+ /**
190
+ * The identifier a request should carry, given what the caller asked for.
191
+ *
192
+ * Never throws: a catalogue that cannot be read leaves the requested identifier
193
+ * alone, which is the same answer as "the catalogue has not loaded yet". A
194
+ * consumer that wants to KNOW the catalogue is unreadable calls
195
+ * {@link fetchCatalogue} directly.
196
+ */
197
+ export async function resolveModelId(
198
+ apiUrl: string,
199
+ requestedId: string,
200
+ accessToken?: string,
201
+ preferredId?: string,
202
+ ): Promise<string> {
203
+ try {
204
+ const entries = await fetchCatalogue(apiUrl, accessToken);
205
+ return resolveSelection(requestedId, entries, preferredId).effectiveId;
206
+ } catch {
207
+ return requestedId;
208
+ }
209
+ }