@chipmobilesdk/rn-ai 0.2.1 → 0.4.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.
@@ -1,3 +1,5 @@
1
+ import { createListenerSet, createSingleFlight, type KeyValuePort } from '@chipmobilesdk/rn-core';
2
+
1
3
  import { AiError } from '../diagnostics/errors';
2
4
  import { DEFAULT_AI_TIMEOUTS, withTimeout } from '../transport/timeouts';
3
5
 
@@ -33,18 +35,84 @@ export class MemoryConfigCache implements AiConfigCache {
33
35
  }
34
36
  }
35
37
 
38
+ /**
39
+ * An `AiConfigCache` over rn-core's synchronous `KeyValuePort` (AI-10).
40
+ *
41
+ * Without a durable cache every launch waits for the network before the first
42
+ * chat, which is exactly what the cache-first design exists to avoid. Open the
43
+ * port the way the app opens its other durable state, for example
44
+ * `openDurable(() => createMMKV({ id: 'app' }))`.
45
+ */
46
+ export function configCacheFromKeyValue(port: KeyValuePort): AiConfigCache {
47
+ return {
48
+ async get(key) {
49
+ return port.getString(key) ?? null;
50
+ },
51
+ async set(key, value) {
52
+ port.set(key, value);
53
+ },
54
+ };
55
+ }
56
+
36
57
  export type BehaviorState = 'unloaded' | 'ready-network' | 'ready-cache' | 'unavailable';
37
58
 
59
+ /** The newest document layout this release understands. */
60
+ export const SUPPORTED_SCHEMA_VERSION = 1;
61
+
62
+ /** Bounds on the remote-configured recovery, so a bad document cannot stall a chat. */
63
+ const MAX_SILENT_RETRIES = 5;
64
+ const MIN_REFRESH_TTL_SECONDS = 30;
65
+ const DEFAULT_REFRESH_TTL_SECONDS = 21_600;
66
+
38
67
  const numbers = (value: unknown): number[] | undefined =>
39
68
  Array.isArray(value) ? value.filter((n): n is number => Number.isFinite(n)) : undefined;
40
69
 
41
- /** Parse + validate; unknown fields ignored (forward compatibility). */
42
- export function parseBehaviorConfig(json: string): AppBehaviorConfig {
70
+ const positiveInt = (value: unknown, fallback: number): number =>
71
+ typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
72
+
73
+ function invalid(reason: string): never {
74
+ throw new AiError('config-unavailable', `behavior config ${reason}`);
75
+ }
76
+
77
+ /** https, or http on a development machine: a bearer token travels to this host. */
78
+ export function isTrustedUrl(url: string): boolean {
79
+ if (/^https:\/\//i.test(url)) {
80
+ return true;
81
+ }
82
+ return /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(url);
83
+ }
84
+
85
+ function hostOf(url: string): string {
86
+ return url
87
+ .replace(/^[a-z]+:\/\//i, '')
88
+ .split(/[/:?#]/)[0]!
89
+ .toLowerCase();
90
+ }
91
+
92
+ export interface ParseBehaviorConfigOptions {
93
+ /** The app this document must be for; a document for another app is refused. */
94
+ expectedAppId?: string;
95
+ /** Hosts `api.baseUrl` may name. Any host is accepted when omitted. */
96
+ allowedHosts?: readonly string[];
97
+ }
98
+
99
+ /**
100
+ * Parse + validate; unknown fields ignored (forward compatibility).
101
+ *
102
+ * Every field this package acts on is checked here, because the document is
103
+ * remote and the alternative is a TypeError inside an XHR callback, a revalidate
104
+ * on every single request, or a bearer token sent to a host nobody vetted
105
+ * (AI-06, AI-07).
106
+ */
107
+ export function parseBehaviorConfig(
108
+ json: string,
109
+ options: ParseBehaviorConfigOptions = {},
110
+ ): AppBehaviorConfig {
43
111
  let raw: Record<string, unknown>;
44
112
  try {
45
113
  raw = JSON.parse(json) as Record<string, unknown>;
46
114
  } catch {
47
- throw new AiError('config-unavailable', 'behavior config is not valid JSON');
115
+ invalid('is not valid JSON');
48
116
  }
49
117
  const api = raw.api as Partial<AppBehaviorConfig['api']> | undefined;
50
118
  if (
@@ -52,17 +120,42 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
52
120
  typeof api?.baseUrl !== 'string' ||
53
121
  !Array.isArray(api.scopes)
54
122
  ) {
55
- throw new AiError('config-unavailable', 'behavior config is missing required fields');
123
+ invalid('is missing required fields');
124
+ }
125
+
126
+ const schemaVersion = positiveInt(raw.schemaVersion, 1);
127
+ if (schemaVersion > SUPPORTED_SCHEMA_VERSION) {
128
+ // A newer layout may mean something different by the same field names.
129
+ invalid(`declares schema version ${schemaVersion}, newer than ${SUPPORTED_SCHEMA_VERSION}`);
56
130
  }
131
+ if (options.expectedAppId !== undefined && raw.appId !== options.expectedAppId) {
132
+ // The cache key and the request path carry the app id; a document for
133
+ // another app would send this app's traffic somewhere else entirely.
134
+ invalid(`is for another app (${raw.appId})`);
135
+ }
136
+
137
+ const baseUrl = api.baseUrl.replace(/\/$/, '');
138
+ if (!isTrustedUrl(baseUrl)) {
139
+ invalid('names a baseUrl that is not https');
140
+ }
141
+ if (options.allowedHosts && !options.allowedHosts.includes(hostOf(baseUrl))) {
142
+ invalid(`names a baseUrl outside the allowed hosts (${hostOf(baseUrl)})`);
143
+ }
144
+
57
145
  const recovery = (raw.recovery ?? {}) as Partial<AppBehaviorConfig['recovery']>;
58
146
  const features = (raw.features ?? {}) as Partial<AppBehaviorConfig['features']>;
59
147
  return {
60
- schemaVersion: Number(raw.schemaVersion ?? 1),
148
+ schemaVersion,
61
149
  appId: raw.appId,
62
- configVersion: Number(raw.configVersion ?? 0),
63
- refreshTtlSeconds: Number(raw.refreshTtlSeconds ?? 21600),
150
+ configVersion: positiveInt(raw.configVersion, 0),
151
+ // A TTL that was NaN made `isFresh` false forever, so every request
152
+ // revalidated over the network (AI-06).
153
+ refreshTtlSeconds: Math.max(
154
+ MIN_REFRESH_TTL_SECONDS,
155
+ positiveInt(raw.refreshTtlSeconds, DEFAULT_REFRESH_TTL_SECONDS),
156
+ ),
64
157
  api: {
65
- baseUrl: api.baseUrl.replace(/\/$/, ''),
158
+ baseUrl,
66
159
  scopes: api.scopes.filter((s): s is string => typeof s === 'string'),
67
160
  supportsStreaming: api.supportsStreaming !== false,
68
161
  },
@@ -71,9 +164,12 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
71
164
  // throw a TypeError inside the XHR error callback, where nothing caught it.
72
165
  recovery: {
73
166
  reloadOnHttpStatus: numbers(recovery.reloadOnHttpStatus) ?? [404, 410],
74
- maxSilentRetries: Number.isFinite(recovery.maxSilentRetries)
75
- ? Number(recovery.maxSilentRetries)
76
- : 1,
167
+ maxSilentRetries: Math.min(
168
+ MAX_SILENT_RETRIES,
169
+ Number.isFinite(recovery.maxSilentRetries)
170
+ ? Math.max(0, Number(recovery.maxSilentRetries))
171
+ : 1,
172
+ ),
77
173
  retryBackoffMs: numbers(recovery.retryBackoffMs) ?? [500, 2000],
78
174
  },
79
175
  features: { aiEnabled: features.aiEnabled !== false },
@@ -94,6 +190,8 @@ export interface BehaviorConfigDeps {
94
190
  now?: () => number;
95
191
  /** Limit on one config fetch, body included. Defaults to 10 s. */
96
192
  timeoutMs?: number;
193
+ /** Hosts the document's `api.baseUrl` may name. */
194
+ allowedHosts?: readonly string[];
97
195
  }
98
196
 
99
197
  /**
@@ -106,64 +204,125 @@ export class BehaviorConfigManager {
106
204
  private fetchFn: typeof fetch;
107
205
  private now: () => number;
108
206
  private current?: { config: AppBehaviorConfig; envelope: CacheEnvelope };
109
- state: BehaviorState = 'unloaded';
207
+ /**
208
+ * One cache read and one network fetch at a time (AI-08): a cold start makes a
209
+ * chat and a quota call together, and each used to fetch the config itself.
210
+ */
211
+ private readonly cacheRead = createSingleFlight<void>();
212
+ private readonly networkFetch = createSingleFlight<AppBehaviorConfig>();
213
+ private currentState: BehaviorState = 'unloaded';
214
+ private readonly stateListeners = createListenerSet<[]>();
215
+
216
+ /** Where the config came from, or why there is none. */
217
+ get state(): BehaviorState {
218
+ return this.currentState;
219
+ }
220
+
221
+ /** Called whenever `state` changes (AI-09). */
222
+ subscribe(listener: () => void): () => void {
223
+ return this.stateListeners.add(listener);
224
+ }
225
+
226
+ private setState(next: BehaviorState): void {
227
+ if (next === this.currentState) return;
228
+ this.currentState = next;
229
+ this.stateListeners.notify();
230
+ }
110
231
 
111
232
  constructor(private deps: BehaviorConfigDeps) {
233
+ if (!isTrustedUrl(deps.configUrl)) {
234
+ // The document decides where the tokens go, so where it comes from
235
+ // matters as much (AI-07).
236
+ throw new AiError('config-unavailable', 'configUrl must be https');
237
+ }
112
238
  this.cacheKey = `rn-ai:config:${deps.appId}`;
113
239
  this.cache = deps.cache ?? new MemoryConfigCache();
114
240
  this.fetchFn = deps.fetchFn ?? fetch;
115
241
  this.now = deps.now ?? Date.now;
116
242
  }
117
243
 
244
+ private parse(json: string): AppBehaviorConfig {
245
+ return parseBehaviorConfig(json, {
246
+ expectedAppId: this.deps.appId,
247
+ ...(this.deps.allowedHosts ? { allowedHosts: this.deps.allowedHosts } : {}),
248
+ });
249
+ }
250
+
118
251
  private isFresh(envelope: CacheEnvelope, config: AppBehaviorConfig): boolean {
119
252
  return this.now() - envelope.fetchedAt < config.refreshTtlSeconds * 1000;
120
253
  }
121
254
 
122
- private async loadFromCache(): Promise<void> {
255
+ private loadFromCache(): Promise<void> {
256
+ return this.current ? Promise.resolve() : this.cacheRead.run(() => this.readCache());
257
+ }
258
+
259
+ private async readCache(): Promise<void> {
123
260
  if (this.current) return;
124
261
  const stored = await this.cache.get(this.cacheKey).catch(() => null);
125
262
  if (!stored) return;
126
263
  try {
127
264
  const envelope = JSON.parse(stored) as CacheEnvelope;
128
- this.current = { config: parseBehaviorConfig(envelope.configJson), envelope };
129
- this.state = 'ready-cache';
265
+ this.current = { config: this.parse(envelope.configJson), envelope };
266
+ this.setState('ready-cache');
130
267
  } catch {
131
268
  // corrupted cache entries are ignored, not fatal
132
269
  }
133
270
  }
134
271
 
135
- private async fetchNetwork(): Promise<AppBehaviorConfig | undefined> {
272
+ private fetchNetwork(): Promise<AppBehaviorConfig> {
273
+ return this.networkFetch.run(() => this.fetchFromNetwork());
274
+ }
275
+
276
+ /** One attempt; `conditional` sends the stored ETag when there is one. */
277
+ private async fetchOnce(
278
+ conditional: boolean,
279
+ ): Promise<{ status: number; text: string; etag?: string }> {
136
280
  const headers: Record<string, string> = {};
137
- if (this.current?.envelope.etag) {
138
- headers['If-None-Match'] = this.current.envelope.etag;
281
+ const etag = this.current?.envelope.etag;
282
+ if (conditional && etag) {
283
+ headers['If-None-Match'] = etag;
139
284
  }
140
285
  // Bounded: with no cache, a hung fetch used to block every chat and quota call.
141
- const { response, configJson } = await withTimeout(
286
+ return withTimeout(
142
287
  this.deps.timeoutMs ?? DEFAULT_AI_TIMEOUTS.configMs,
143
288
  async signal => {
144
- const res = await this.fetchFn(this.deps.configUrl, { headers, signal });
145
- return { response: res, configJson: res.ok && res.status !== 304 ? await res.text() : '' };
289
+ const response = await this.fetchFn(this.deps.configUrl, { headers, signal });
290
+ return {
291
+ status: response.status,
292
+ text: response.ok && response.status !== 304 ? await response.text() : '',
293
+ etag: response.headers.get('etag') ?? undefined,
294
+ };
146
295
  },
147
296
  () => new AiError('config-unavailable', 'config fetch timed out'),
148
297
  );
149
- if (response.status === 304 && this.current) {
150
- this.current.envelope.fetchedAt = this.now();
151
- await this.persist(this.current.envelope);
152
- this.state = 'ready-network';
153
- return this.current.config;
298
+ }
299
+
300
+ private async fetchFromNetwork(): Promise<AppBehaviorConfig> {
301
+ let result = await this.fetchOnce(true);
302
+ if (result.status === 304) {
303
+ if (this.current) {
304
+ this.current.envelope.fetchedAt = this.now();
305
+ await this.persist(this.current.envelope);
306
+ this.setState('ready-network');
307
+ return this.current.config;
308
+ }
309
+ // Not-modified against a cache that is no longer there (it was cleared,
310
+ // or it failed to parse). Asking again without the ETag is the answer;
311
+ // reporting `config fetch failed: 304` was not (AI-17).
312
+ result = await this.fetchOnce(false);
154
313
  }
155
- if (!response.ok) {
156
- throw new AiError('config-unavailable', `config fetch failed: ${response.status}`);
314
+ if (result.status < 200 || result.status >= 300) {
315
+ throw new AiError('config-unavailable', `config fetch failed: ${result.status}`);
157
316
  }
158
- const config = parseBehaviorConfig(configJson);
317
+ const config = this.parse(result.text);
159
318
  const envelope: CacheEnvelope = {
160
- configJson,
161
- etag: response.headers.get('etag') ?? undefined,
319
+ configJson: result.text,
320
+ etag: result.etag,
162
321
  fetchedAt: this.now(),
163
322
  };
164
323
  this.current = { config, envelope };
165
324
  await this.persist(envelope);
166
- this.state = 'ready-network';
325
+ this.setState('ready-network');
167
326
  return config;
168
327
  }
169
328
 
@@ -185,16 +344,11 @@ export class BehaviorConfigManager {
185
344
  return this.current.config;
186
345
  }
187
346
  try {
188
- const fetched = await this.fetchNetwork();
189
- if (fetched) return fetched;
347
+ return await this.fetchNetwork();
190
348
  } catch (e) {
191
349
  if (this.current) return this.current.config; // stale beats broken
192
- this.state = 'unavailable';
350
+ this.setState('unavailable');
193
351
  throw e instanceof AiError ? e : new AiError('config-unavailable', String(e));
194
352
  }
195
- // fetchNetwork only returns undefined when a 304 raced an empty cache.
196
- if (this.current) return (this.current as { config: AppBehaviorConfig }).config;
197
- this.state = 'unavailable';
198
- throw new AiError('config-unavailable', 'no configuration available');
199
353
  }
200
354
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The gateway↔client contract: error codes, quota headers, stream events.
3
+ *
4
+ * One definition, imported by both sides (GW-13). The gateway's own codes and
5
+ * the client's map of them were two hand-maintained lists, and they had already
6
+ * drifted: `BAD_REQUEST`, `ENDPOINT_RETIRED` and `INTERNAL` existed on the
7
+ * server and were unknown to the client, which fell back to a guess from the
8
+ * status. Adding a code now fails to compile on whichever side has not handled
9
+ * it.
10
+ *
11
+ * Types and plain constants only, and no import of anything: the gateway runs
12
+ * on Node and imports this file for its types alone.
13
+ */
14
+
15
+ /** Every error the gateway answers with (`error.code` in the body). */
16
+ export type GatewayErrorCode =
17
+ | 'BAD_REQUEST'
18
+ | 'AUTH_INVALID'
19
+ | 'NOT_ENTITLED'
20
+ | 'USER_BLOCKED'
21
+ | 'APP_UNKNOWN'
22
+ | 'ENDPOINT_RETIRED'
23
+ | 'PAYLOAD_TOO_LARGE'
24
+ | 'MODALITY_NOT_ALLOWED'
25
+ | 'RATE_LIMITED'
26
+ | 'UPSTREAM_BUSY'
27
+ | 'UPSTREAM_ERROR'
28
+ | 'INTERNAL';
29
+
30
+ /** The HTTP status each code is sent with. */
31
+ export const GATEWAY_ERROR_STATUS: Readonly<Record<GatewayErrorCode, number>> = {
32
+ BAD_REQUEST: 400,
33
+ AUTH_INVALID: 401,
34
+ NOT_ENTITLED: 403,
35
+ USER_BLOCKED: 403,
36
+ APP_UNKNOWN: 404,
37
+ ENDPOINT_RETIRED: 410,
38
+ PAYLOAD_TOO_LARGE: 413,
39
+ MODALITY_NOT_ALLOWED: 415,
40
+ RATE_LIMITED: 429,
41
+ UPSTREAM_BUSY: 429,
42
+ UPSTREAM_ERROR: 500,
43
+ INTERNAL: 500,
44
+ };
45
+
46
+ /** The error body. `message` is safe to log; it never carries upstream detail. */
47
+ export interface GatewayErrorBody {
48
+ error: {
49
+ code: GatewayErrorCode;
50
+ message: string;
51
+ retryAfterSeconds?: number;
52
+ };
53
+ }
54
+
55
+ /** The quota headers, by name. A rename here breaks both sides' compile. */
56
+ export const QUOTA_HEADERS = {
57
+ minuteRemaining: 'X-Quota-Minute-Remaining',
58
+ dayRemaining: 'X-Quota-Day-Remaining',
59
+ dayTokensRemaining: 'X-Quota-Day-Tokens-Remaining',
60
+ } as const;
61
+
62
+ /** The names field by field, so a side declaring its own literals is checked exactly. */
63
+ export type QuotaHeaderNames = typeof QUOTA_HEADERS;
64
+ export type QuotaHeaderName = QuotaHeaderNames[keyof QuotaHeaderNames];
65
+
66
+ /** What is left of an app's allowance, as the gateway reports it. */
67
+ export interface GatewayQuota {
68
+ minuteRemaining: number;
69
+ dayRemaining: number;
70
+ dayTokensRemaining: number;
71
+ }
72
+
73
+ /** What one answer cost. */
74
+ export interface GatewayUsage {
75
+ promptTokens: number;
76
+ completionTokens: number;
77
+ totalTokens: number;
78
+ }
79
+
80
+ /** The `data:` payloads of a chat stream, before the `[DONE]` sentinel. */
81
+ export type GatewayStreamEvent =
82
+ | { type: 'delta'; text: string }
83
+ | { type: 'done'; usage: GatewayUsage; quota?: GatewayQuota }
84
+ | { type: 'error'; code: GatewayErrorCode };
85
+
86
+ /** The last `data:` line of a stream that completed. */
87
+ export const SSE_DONE_SENTINEL = '[DONE]';
88
+ export type SseDoneSentinel = typeof SSE_DONE_SENTINEL;
89
+
90
+ /** The `Retry-After` header, which a 429 always carries. */
91
+ export const RETRY_AFTER_HEADER = 'Retry-After';