@alia.onl/sdk 5.1.0 → 7.0.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.
@@ -21,6 +21,7 @@ declare module "react-native" {
21
21
  }
22
22
 
23
23
  type ChatTextInputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
24
+ unstyled?: boolean;
24
25
  noFocus?: boolean;
25
26
  onEnterPress?: () => void;
26
27
  onCompletionKey?: (key: string) => boolean;
@@ -36,6 +37,7 @@ type ChatTextInputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
36
37
  const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
37
38
  ({
38
39
  className,
40
+ unstyled = false,
39
41
  noFocus = false,
40
42
  onEnterPress,
41
43
  onCompletionKey,
@@ -54,6 +56,28 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
54
56
  const inputRef = React.useRef<TextInput>(null);
55
57
  const wrapperRef = React.useRef<View>(null);
56
58
 
59
+ // react-native-css observes `style` by reference. Memoizing the composed
60
+ // value prevents its own state update from manufacturing another changed
61
+ // style prop and recursively re-rendering this TextInput on native.
62
+ const inputStyle = React.useMemo(() => [
63
+ style,
64
+ !fillContainer && props.multiline && !disableAutoHeight && ({
65
+ minHeight,
66
+ maxHeight,
67
+ ...(Platform.OS === 'web'
68
+ ? { overflow: 'auto' as const, fieldSizing: 'content' }
69
+ : {}),
70
+ } as TextStyle & { fieldSizing?: string }),
71
+ fillContainer && { flex: 1, height: '100%' as const },
72
+ ], [
73
+ disableAutoHeight,
74
+ fillContainer,
75
+ maxHeight,
76
+ minHeight,
77
+ props.multiline,
78
+ style,
79
+ ]);
80
+
57
81
  React.useImperativeHandle(ref, () => inputRef.current as TextInput);
58
82
 
59
83
  // Attach paste event listener (web only)
@@ -135,7 +159,8 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
135
159
  <TextInput
136
160
  ref={inputRef}
137
161
  className={cn(
138
- "native:text-md native:leading-[1.25] rounded-xl border border-input bg-background px-3.5 text-base text-foreground file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground web:flex web:w-full web:py-2 lg:text-sm",
162
+ "native:text-md native:leading-[1.25] text-base text-foreground file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground web:flex web:w-full web:py-2 lg:text-sm",
163
+ !unstyled && "rounded-xl border border-input bg-background px-3.5",
139
164
  "web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
140
165
  !fillContainer && !props.multiline && "h-9",
141
166
  fillContainer && "h-full",
@@ -147,16 +172,7 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
147
172
  onKeyPress={handleKeyPress}
148
173
  onContentSizeChange={handleContentSizeChange}
149
174
  scrollEnabled={fillContainer || props.multiline}
150
- style={[
151
- style,
152
- !fillContainer && props.multiline && !disableAutoHeight && ({
153
- minHeight,
154
- maxHeight,
155
- overflow: 'auto',
156
- ...(Platform.OS === 'web' ? { fieldSizing: 'content' } : {}),
157
- } as TextStyle & { overflow: 'auto'; fieldSizing?: string }),
158
- fillContainer && { flex: 1, height: '100%' },
159
- ]}
175
+ style={inputStyle}
160
176
  {...props}
161
177
  />
162
178
  </View>
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { View } from "react-native";
2
+ import { StyleSheet, View } from "react-native";
3
3
  import { cn } from "../../../lib/utils";
4
4
  import { ChatTextInput } from "../chat-text-input";
5
5
  import { usePromptInput } from "./context";
@@ -28,6 +28,11 @@ export function PromptInputTextarea({
28
28
  handleCompletionKey,
29
29
  } = usePromptInput();
30
30
 
31
+ const inputStyle = React.useMemo(
32
+ () => [style, styles.borderless, isFullscreen && styles.fullscreen],
33
+ [isFullscreen, style],
34
+ );
35
+
31
36
  const textInput = (
32
37
  <ChatTextInput
33
38
  ref={textareaRef}
@@ -42,12 +47,14 @@ export function PromptInputTextarea({
42
47
  maxHeight={isFullscreen ? 10000 : maxHeight}
43
48
  onImagePaste={onImagePaste}
44
49
  fillContainer={isFullscreen}
50
+ unstyled
45
51
  className={cn(
46
- "w-full border-0 bg-transparent text-foreground shadow-none",
52
+ "w-full border-0 bg-transparent text-foreground web:shadow-none",
47
53
  isFullscreen ? "px-4 pt-4" : "min-h-[44px] py-3",
48
54
  className
49
55
  )}
50
- style={[style, isFullscreen && { paddingBottom: 100 }]}
56
+ style={inputStyle}
57
+ underlineColorAndroid="transparent"
51
58
  placeholder={placeholder}
52
59
  multiline
53
60
  editable={!disabled}
@@ -62,3 +69,12 @@ export function PromptInputTextarea({
62
69
 
63
70
  return textInput;
64
71
  }
72
+
73
+ const styles = StyleSheet.create({
74
+ borderless: {
75
+ borderWidth: 0,
76
+ },
77
+ fullscreen: {
78
+ paddingBottom: 100,
79
+ },
80
+ });
@@ -2,13 +2,22 @@ import React, { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import { useOxy } from '@oxyhq/services';
3
3
  import type { ChatMessage, ToolInvocation } from '../types';
4
4
  import { getTextFromContent } from '../lib/content-utils';
5
+ import { resolveModelId } from '../lib/catalogue';
6
+ import { PREFERRED_CHAT_MODEL_ID } from '../lib/config';
5
7
 
6
8
  const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
7
9
 
8
10
  export interface UseAliaChatOptions {
9
11
  /** Alia API base URL (default: EXPO_PUBLIC_ALIA_API_URL or https://api.alia.onl) */
10
12
  apiUrl?: string;
11
- /** Alia model to use (default: 'alia-v1') */
13
+ /**
14
+ * Alia model to use.
15
+ *
16
+ * Optional, and checked against `GET /catalogue` before a request carries it:
17
+ * an identifier the server no longer offers is replaced rather than sent, so a
18
+ * retirement does not turn into a 400 inside a consumer's app. Omitted, the
19
+ * build's `PREFERRED_CHAT_MODEL_ID` is what gets checked.
20
+ */
12
21
  model?: string;
13
22
  /** App context injected as system message so Alia knows which app the user is in */
14
23
  clientContext?: string;
@@ -43,7 +52,7 @@ function generateId(): string {
43
52
  export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
44
53
  const {
45
54
  apiUrl = API_URL,
46
- model = 'alia-v1',
55
+ model = PREFERRED_CHAT_MODEL_ID,
47
56
  clientContext,
48
57
  accessToken: accessTokenProp,
49
58
  } = options;
@@ -214,6 +223,40 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
214
223
  toolInvocationsRef.current = [];
215
224
 
216
225
  try {
226
+ /**
227
+ * This SDK stays on `/v1/chat/completions`, and that is a decision
228
+ * rather than an oversight — epic #139 workstream 6.
229
+ *
230
+ * Every other first-party Alia client moved to the product runtime at
231
+ * `POST /alia/chat`, which serves the identical handler. This one cannot
232
+ * follow, for two reasons that are independent of each other.
233
+ *
234
+ * **CORS.** `/v1` is the only surface with the public wildcard policy;
235
+ * everything else falls to the Oxy allowlist
236
+ * (`packages/api/src/index.ts`). Measured 2026-08-19 against the running
237
+ * service: an `OPTIONS /alia/chat` preflight from
238
+ * `https://alia.onl` and `https://console.alia.onl` is answered with a
239
+ * matching `access-control-allow-origin`, and one from an unlisted
240
+ * origin comes back with NO such header, so a browser blocks the
241
+ * request. `OPTIONS /v1/chat/completions` answers `*` for all three.
242
+ * This package is installed by apps on origins Alia does not enumerate,
243
+ * so the product surface would refuse them. Widening the allowlist is
244
+ * not the repair: the narrow policy is what makes `/alia/chat` a product
245
+ * surface, and it is one of the three recorded differences in
246
+ * `packages/api/src/routes/__tests__/v1-compatibility-surface.test.ts`.
247
+ *
248
+ * **Raw source.** `@alia.onl/sdk` publishes `src/`, so consumers' own
249
+ * Metro and tsc compile this line and it changes what every installed
250
+ * copy sends on its NEXT build — not on a release this repository
251
+ * controls. `docs/migration/compatibility-window.md` records the same
252
+ * property for the aliases: installed copies resume sending whatever
253
+ * their source says the moment the service is reachable.
254
+ *
255
+ * The consequence is recorded rather than hidden: `/v1/chat/completions`
256
+ * cannot reach its removal gate while this SDK is a consumer, so
257
+ * migrating it is a browser-CORS decision on the product surface, taken
258
+ * with the SDK's consumers enumerated.
259
+ */
217
260
  const response = await fetch(`${apiUrl}/v1/chat/completions`, {
218
261
  method: 'POST',
219
262
  headers: {
@@ -221,7 +264,11 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
221
264
  Authorization: `Bearer ${token}`,
222
265
  },
223
266
  body: JSON.stringify({
224
- model,
267
+ // Resolved HERE rather than at render: this is the moment a request
268
+ // is about to name a model, and it is the only moment at which
269
+ // sending a retired identifier would actually cost anything. A
270
+ // catalogue that cannot be read leaves the choice alone.
271
+ model: await resolveModelId(apiUrl, model, token, PREFERRED_CHAT_MODEL_ID),
225
272
  messages: apiMessages,
226
273
  stream: true,
227
274
  }),
@@ -11,6 +11,7 @@ import {
11
11
  import { useOxy } from '@oxyhq/services';
12
12
  import { errorMessage } from '../lib/utils';
13
13
  import { create } from 'zustand';
14
+ import { PREFERRED_VOICE_MODEL_ID } from '../lib/config';
14
15
 
15
16
  const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
16
17
 
@@ -21,6 +22,15 @@ export interface UseTTSOptions {
21
22
  accessToken?: string;
22
23
  voice?: 'male' | 'female';
23
24
  tone?: 'brief' | 'chill' | 'default';
25
+ /**
26
+ * Speech model. Defaults to the build's `PREFERRED_VOICE_MODEL_ID`.
27
+ *
28
+ * Not checked against `GET /catalogue`: that surface describes what a chat
29
+ * picker may offer and its resolver filters to `chat_visible` entries, so
30
+ * passing a voice identifier through it would substitute a chat model that
31
+ * cannot speak. See `lib/config.ts`.
32
+ */
33
+ model?: string;
24
34
  }
25
35
 
26
36
  // ============== INLINE TTS STORE ==============
@@ -52,6 +62,7 @@ const useTTSStore = create<TTSStore>((set) => ({
52
62
  export function useTTS(options: UseTTSOptions = {}) {
53
63
  const apiUrl = options.apiUrl || API_URL;
54
64
  const voicePref = options.voice ?? 'female';
65
+ const voiceModel = options.model ?? PREFERRED_VOICE_MODEL_ID;
55
66
  const tonePref = options.tone ?? 'default';
56
67
 
57
68
  const { oxyServices } = useOxy();
@@ -120,7 +131,21 @@ export function useTTS(options: UseTTSOptions = {}) {
120
131
  reset();
121
132
  }, [reset, releasePlayer]);
122
133
 
123
- const playFromUrl = useCallback((audioUrl: string, _messageId: string) => {
134
+ /**
135
+ * Play a stored clip.
136
+ *
137
+ * `onUnplayable` is how a caller says "I can make this again". Stored audio
138
+ * is not permanent — `tts/` objects age out — and a link to something that is
139
+ * gone fails in the player, not at the request, so the only place to notice
140
+ * is here. Without a fallback the failure is reported; with one, the caller
141
+ * gets to rebuild rather than the person getting an error about a button they
142
+ * pressed twice.
143
+ */
144
+ const playFromUrl = useCallback((
145
+ audioUrl: string,
146
+ _messageId: string,
147
+ onUnplayable?: () => void,
148
+ ) => {
124
149
  releasePlayer();
125
150
 
126
151
  (async () => {
@@ -132,6 +157,10 @@ export function useTTS(options: UseTTSOptions = {}) {
132
157
  player.addListener('playbackStatusUpdate', (status: AudioStatus) => {
133
158
  if (status.error) {
134
159
  releasePlayer();
160
+ if (onUnplayable) {
161
+ onUnplayable();
162
+ return;
163
+ }
135
164
  setError(status.error);
136
165
  return;
137
166
  }
@@ -144,6 +173,10 @@ export function useTTS(options: UseTTSOptions = {}) {
144
173
  player.play();
145
174
  setPlaybackState('playing');
146
175
  } catch {
176
+ if (onUnplayable) {
177
+ onUnplayable();
178
+ return;
179
+ }
147
180
  setError('Audio playback not available');
148
181
  }
149
182
  })();
@@ -183,44 +216,64 @@ export function useTTS(options: UseTTSOptions = {}) {
183
216
  setActiveMessage(messageId);
184
217
  setPlaybackState('loading');
185
218
 
186
- // If cached audioUrl exists, play directly
187
- if (audioUrl) {
188
- playFromUrl(audioUrl, messageId);
189
- return;
190
- }
219
+ /**
220
+ * Ask the server to make it, and play what comes back.
221
+ *
222
+ * Hoisted out of the cached branch below so a clip that has aged out of
223
+ * storage can fall back to it. `playFromUrl` is called WITHOUT a fallback
224
+ * here: a freshly made clip that will not play is a real failure, and
225
+ * retrying it would be a loop.
226
+ */
227
+ const synthesize = async () => {
228
+ const token = getToken();
229
+ if (!token) {
230
+ throw new Error('Not authenticated');
231
+ }
191
232
 
192
- // Call backend TTS API
193
- const token = getToken();
194
- if (!token) {
195
- throw new Error('Not authenticated');
196
- }
233
+ const response = await fetch(`${apiUrl}/v1/audio/speech`, {
234
+ method: 'POST',
235
+ headers: {
236
+ 'Content-Type': 'application/json',
237
+ Authorization: `Bearer ${token}`,
238
+ },
239
+ body: JSON.stringify({
240
+ model: voiceModel,
241
+ input: text,
242
+ voice: getTTSVoice(),
243
+ speed: getTTSSpeed(),
244
+ conversationId,
245
+ messageId,
246
+ }),
247
+ });
197
248
 
198
- const response = await fetch(`${apiUrl}/v1/audio/speech`, {
199
- method: 'POST',
200
- headers: {
201
- 'Content-Type': 'application/json',
202
- Authorization: `Bearer ${token}`,
203
- },
204
- body: JSON.stringify({
205
- model: 'alia-v1-voice',
206
- input: text,
207
- voice: getTTSVoice(),
208
- speed: getTTSSpeed(),
209
- conversationId,
210
- messageId,
211
- }),
212
- });
213
-
214
- if (!response.ok) {
215
- if (response.status === 504) {
216
- throw new Error('Request timed out — please try again');
249
+ if (!response.ok) {
250
+ if (response.status === 504) {
251
+ throw new Error('Request timed out — please try again');
252
+ }
253
+ const errData = await response.json().catch(() => ({}));
254
+ throw new Error(errData.error?.message || errData.error || 'TTS failed');
217
255
  }
218
- const errData = await response.json().catch(() => ({}));
219
- throw new Error(errData.error?.message || errData.error || 'TTS failed');
256
+
257
+ const data = await response.json();
258
+ playFromUrl(data.audioUrl, messageId);
259
+ };
260
+
261
+ /**
262
+ * A stored clip plays straight away, and is remade if it has gone.
263
+ *
264
+ * The row keeps its key long after a `tts/` object ages out of storage,
265
+ * so the link is signed, valid, and points at nothing — a failure only
266
+ * the player ever sees. Remaking it is cheaper than keeping every clip
267
+ * forever, and the person notices nothing.
268
+ */
269
+ if (audioUrl) {
270
+ playFromUrl(audioUrl, messageId, () => {
271
+ void synthesize().catch((e: unknown) => setError(errorMessage(e, 'Failed to read aloud')));
272
+ });
273
+ return;
220
274
  }
221
275
 
222
- const data = await response.json();
223
- playFromUrl(data.audioUrl, messageId);
276
+ await synthesize();
224
277
  } catch (e: unknown) {
225
278
  console.error('[TTS] Error:', e);
226
279
  setError(errorMessage(e, 'Failed to read aloud'));
@@ -20,6 +20,7 @@ import {
20
20
  import { useOxy } from '@oxyhq/services';
21
21
  import { errorMessage } from '../lib/utils';
22
22
  import type { RoomState, AgentState, VoiceMessage, VoiceToolInvocation } from '../types';
23
+ import { PREFERRED_VOICE_MODEL_ID } from '../lib/config';
23
24
 
24
25
  const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
25
26
 
@@ -33,6 +34,23 @@ export interface UseVoiceRoomOptions {
33
34
  apiUrl?: string;
34
35
  voicePreference?: 'male' | 'female';
35
36
  accessToken?: string;
37
+ /**
38
+ * Voice session model. Defaults to the build's `PREFERRED_VOICE_MODEL_ID`.
39
+ *
40
+ * Not checked against `GET /catalogue`, for the reason given in
41
+ * `lib/config.ts`: the chat resolver filters to `chat_visible` entries and
42
+ * would substitute something that cannot hold a voice session.
43
+ */
44
+ model?: string;
45
+ /**
46
+ * The agent this session belongs to, when it is a thread with one.
47
+ *
48
+ * Without it the session is ordinary Alia: the API composes the generic
49
+ * prompt and the identity guard says the model's name, so an agent's voice
50
+ * answers as Alia. It is the caller's to supply because only the screen knows
51
+ * whose thread is open.
52
+ */
53
+ agentId?: string;
36
54
  }
37
55
 
38
56
  // ============== TITLE TAG UTILITIES ==============
@@ -105,6 +123,8 @@ function micDeviceErrorMessage(name: string): string | null {
105
123
  export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
106
124
  const apiUrl = options.apiUrl || API_URL;
107
125
  const voicePref = options.voicePreference ?? 'female';
126
+ const voiceModel = options.model ?? PREFERRED_VOICE_MODEL_ID;
127
+ const agentId = options.agentId;
108
128
 
109
129
  const [roomState, setRoomState] = useState<RoomState>('disconnected');
110
130
  const [agentState, setAgentState] = useState<AgentState>('idle');
@@ -329,8 +349,12 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
329
349
  Authorization: `Bearer ${token}`,
330
350
  },
331
351
  body: JSON.stringify({
332
- model: 'alia-v1-voice',
352
+ model: voiceModel,
333
353
  voice: voicePref === 'male' ? 'echo' : 'nova',
354
+ // Omitted rather than sent as undefined: the API branches on the key
355
+ // being a string, and a session with no agent must look exactly as it
356
+ // did before this existed.
357
+ ...(agentId ? { agentId } : {}),
334
358
  }),
335
359
  });
336
360
 
@@ -408,7 +432,7 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
408
432
  setRoomState('error');
409
433
  cleanup();
410
434
  }
411
- }, [getToken, cleanup, handleDataMessage, apiUrl, voicePref]);
435
+ }, [getToken, cleanup, handleDataMessage, apiUrl, voicePref, voiceModel, agentId]);
412
436
 
413
437
  // ============== DISCONNECT ==============
414
438
 
package/src/index.ts CHANGED
@@ -9,8 +9,8 @@ export { AliaChatScreen } from './components/AliaChatScreen';
9
9
  export type { AliaChatScreenProps } from './components/AliaChatScreen';
10
10
 
11
11
  // ── Chat UI components ──
12
- export { AliaMark } from './components/AliaMark';
13
- export type { AliaMarkProps, AliaMarkState } from './components/AliaMark';
12
+ export { IdentityMark } from './components/IdentityMark';
13
+ export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
14
14
  export { ThinkingIndicator } from './components/ThinkingIndicator';
15
15
  export { AliaMarkdown } from './components/Markdown';
16
16
  export type { AliaMarkdownProps } from './components/Markdown';
@@ -29,6 +29,19 @@ export type { UseTTSOptions } from './hooks/useTTS';
29
29
  export { useSpeechToText, useSTTStore } from './hooks/useSpeechToText';
30
30
  export type { UseSTTOptions } from './hooks/useSpeechToText';
31
31
 
32
+ // ── Model catalogue (GET /catalogue) ──
33
+ // Exported so a consumer building its own picker reads the surface the SDK
34
+ // reads, rather than hardcoding an identifier the way this package used to.
35
+ export {
36
+ fetchCatalogue,
37
+ parseCatalogue,
38
+ resolveSelection,
39
+ resolveModelId,
40
+ clearCatalogueCache,
41
+ } from './lib/catalogue';
42
+ export type { CatalogueEntry, CatalogueEntryKind, ModelSelection } from './lib/catalogue';
43
+ export { PREFERRED_CHAT_MODEL_ID, PREFERRED_VOICE_MODEL_ID } from './lib/config';
44
+
32
45
  // ── Types ──
33
46
  export type {
34
47
  ChatMessage,
@@ -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
+ }