@chipmobilesdk/rn-ai 0.3.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @chipmobilesdk/rn-ai
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - A 401 retry now gets a token the provider had to fetch again. `TokenProvider.requestAccessToken` takes `{ forceRefresh }`, which rn-auth implements by dropping the cached token for that resource, so the retry no longer re-sends the token the gateway had just refused — a cached token is still unexpired by the device's clock when the server rejects it. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
8
+
9
+ Failures an app can tell apart. `network` is a request that never arrived (no connection, a timeout, a dropped stream) and is retryable; `session-expired` is a session the user must renew. `AiError.cause` carries the code from the layer underneath — the gateway's own code, or rn-auth's — so a screen can explain *why*, and `retryAfterSeconds` is read from the `Retry-After` header when the body omits it. Both new codes have user copy, and the gateway's full code list is mapped rather than guessed from the status.
10
+
11
+ The remote behavior config is checked before it is trusted. `configUrl` and the document's `api.baseUrl` must be https (localhost excepted), the document must name the app it is for, its `schemaVersion` must be one this release understands, and `allowedHosts` pins where the client may send its bearer token. A broken TTL no longer means a revalidation on every request, and a retry count is bounded. A not-modified answer against a cache that is no longer there asks again without the ETag instead of reporting the 304 as a failure.
12
+
13
+ `configCacheFromKeyValue(port)` makes a durable config cache out of rn-core's `KeyValuePort`, so a second launch uses the last-good document instead of waiting for the network.
14
+
15
+ One quota figure: a chat's `done` event and `getQuota()` both record it on the client (`lastQuota()`), so `useAiQuota` and `useAiChat` cannot disagree, and a quota response that does not match the contract is refused rather than displayed. `getQuota()` recovers like a chat: a designated 404/410 reloads the configuration and retries, and a 401 retries once.
16
+
17
+ Streamed text reaches the screen on an interval (50 ms by default, `useAiChat(client, { flushIntervalMs })`) rather than once per token, and whatever arrived is flushed with the terminal state, so a half-answer stays on screen when a stream fails. A stream framed with CRLF is parsed incrementally, as the SSE specification allows. The transport seam is a published `StreamTransport` port instead of the internal reader's signature.
18
+
19
+ - The gateway↔client contract has one definition. `GatewayErrorCode`, `GATEWAY_ERROR_STATUS`, `QUOTA_HEADERS`, `GatewayErrorBody`, `GatewayStreamEvent`, `GatewayQuota`, `GatewayUsage` and `SSE_DONE_SENTINEL` are exported from this package, and the gateway service imports the same types, so an error code, a header name or a stream event added on one side does not compile until the other handles it. The three codes the gateway could already answer with and the client had never heard of — `BAD_REQUEST`, `ENDPOINT_RETIRED`, `INTERNAL` — now map to a typed error instead of a guess from the HTTP status. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
20
+
21
+ ### Patch Changes
22
+
23
+ - The gateway service (not published to npm) now shares one credential across the process and refuses unauthenticated requests before reading storage. No client-facing change; the entry is here so the release notes record the service work that went with this version. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
24
+
25
+ - Internal: no module in these packages is over 500 lines any more. The camera controller's session I/O moved to `sessionRuntime` and its contract to `contract.ts`; the Play adapter's shape translation to `iapShapes`; the purchase flow's failure mapping and its receipt-validation hook to modules of their own; the AI client's quota request to `quotaRequest`; the in-memory adapter's attachments and the fake ads engine's clock likewise. Behaviour is unchanged — the splits are by responsibility, and every package's suite passes as it stood. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
26
+
27
+ The repository also measures what it promised: `npm run perf` prints the median of nine runs of the deterministic hot paths against written budgets (recurrence expansion, theme construction, translation lookup, chart geometry, filter compilation), the demo app has a Performance screen reporting startup phases, time to interactive and the same benchmarks on Hermes, and `npm run check:file-size` keeps the limit from drifting. The budgets and the measured figures are recorded in `docs/standards.md` §VI.
28
+
3
29
  ## 0.3.0
4
30
 
5
31
  ### Minor Changes
package/README.md CHANGED
@@ -12,9 +12,12 @@ OpenAI directly and hold no AI credentials.
12
12
  - **Entrypoint**: `@chipmobilesdk/rn-ai`
13
13
  - **Native code**: none
14
14
 
15
- - Contract: [rn-ai-public-api](../../specs/005-azure-ai-gateway/contracts/rn-ai-public-api.md)
16
- - Gateway API: [gateway-api](../../specs/005-azure-ai-gateway/contracts/gateway-api.md)
17
- - Behavior config: [app-behavior-config](../../specs/005-azure-ai-gateway/contracts/app-behavior-config.md)
15
+ - **Gateway contract**: the error codes, quota headers and stream events this
16
+ client and the gateway share are exported from this package
17
+ (`GatewayErrorCode`, `QUOTA_HEADERS`, `GatewayStreamEvent`), and the gateway
18
+ imports the same definitions — see `## Public API`. The feature's original
19
+ contract documents live in the repository under
20
+ `specs/005-azure-ai-gateway/legacy/contracts/` and are not shipped here.
18
21
 
19
22
  ## Install
20
23
 
@@ -39,7 +42,10 @@ resources: [
39
42
  2. Create the client (one per app):
40
43
 
41
44
  ```ts
42
- import { createAiClient } from '@chipmobilesdk/rn-ai';
45
+ import { configCacheFromKeyValue, createAiClient } from '@chipmobilesdk/rn-ai';
46
+ import { openDurable } from '@chipmobilesdk/rn-core';
47
+ import { createMMKV } from 'react-native-mmkv';
48
+
43
49
  import { auth } from './auth/setup';
44
50
 
45
51
  export const aiClient = createAiClient({
@@ -47,10 +53,21 @@ export const aiClient = createAiClient({
47
53
  configUrl: 'https://<config-host>/appconfig/config/scan-food.json',
48
54
  resourceKey: 'ai-gateway',
49
55
  auth,
50
- // storage: inject a persistent AiConfigCache for offline launches
56
+ // Cache the behavior config where it survives a restart, so the next launch
57
+ // does not wait for the network. Any `AiConfigCache` will do; this one is
58
+ // rn-core's key-value port over MMKV.
59
+ storage: configCacheFromKeyValue(openDurable(() => createMMKV({ id: 'app.ai' }))),
60
+ // Optional: pin the hosts the remote config may point the client at.
61
+ allowedHosts: ['gw.example.com'],
51
62
  });
52
63
  ```
53
64
 
65
+ The config document decides where this client sends its bearer token, so both
66
+ `configUrl` and the document's `api.baseUrl` must be `https` (plain `http` is
67
+ accepted only for `localhost`), the document must name this `appId`, and its
68
+ `schemaVersion` must be one this release understands. Anything else is a
69
+ `config-unavailable` error rather than a request to an unvetted host.
70
+
54
71
  3. Use the hooks:
55
72
 
56
73
  ```tsx
@@ -83,30 +100,46 @@ send({
83
100
  waiting `recovery.retryBackoffMs[n]` (the last step repeats, each capped at
84
101
  30 s), at most `recovery.maxSilentRetries` times. A chat cancelled during the
85
102
  wait is not retried.
86
- - **Auth**: acquires tokens via rn-auth (`requestAccessToken('ai-gateway')`);
87
- on `401`, reacquires a token and retries once.
103
+ - **Auth**: acquires tokens via rn-auth
104
+ (`requestAccessToken('ai-gateway')`); on `401` it asks for a token the
105
+ provider had to fetch again (`{ forceRefresh: true }`) and retries once. The
106
+ cached token is unexpired by the device's clock after a 401, so without that
107
+ the retry would re-send the token the gateway just refused.
108
+ - **Quota**: one figure, wherever it came from. A chat's `done` event and
109
+ `getQuota()` both record it on the client, so `useAiQuota` and `useAiChat`
110
+ never disagree, and a quota display does not go stale when a chat finishes.
111
+ `getQuota()` recovers like a chat does: a designated `404`/`410` reloads the
112
+ configuration and retries, and a `401` retries once with a fresh token.
88
113
  - **Timeouts**: every network call is bounded. Override any of them with
89
114
  `createAiClient({ timeouts: { ... } })`:
90
115
 
91
116
  | | Default | Fails with |
92
117
  |---|---|---|
93
- | `connectMs` — chat request sent → headers | 30 s | `upstream-error`, `retryable: true` |
94
- | `idleMs` — silence between two stream chunks | 30 s | `upstream-error`, `retryable: true` |
118
+ | `connectMs` — chat request sent → headers | 30 s | `network`, `retryable: true` |
119
+ | `idleMs` — silence between two stream chunks | 30 s | `network`, `retryable: true` |
95
120
  | `configMs` — behavior-config fetch | 10 s | stale cache if any, else `config-unavailable` |
96
- | `quotaMs` — quota fetch | 10 s | `upstream-error`, `retryable: true` |
121
+ | `quotaMs` — quota fetch | 10 s | `network`, `retryable: true` |
97
122
 
98
123
  The gateway sends headers only after the model answers, so `connectMs` also
99
124
  covers time to first token. A dropped connection (HTTP status 0) is likewise
100
- `upstream-error` with `retryable: true`.
125
+ `network` with `retryable: true`: the request never reached the service, which
126
+ is a different thing to tell the user than a service that failed.
101
127
  - **Callbacks**: each `chat()` delivers exactly one of `onDone` / `onError`,
102
128
  and nothing after `cancel()`. An unexpected exception anywhere on the way
103
129
  (including in your own `onDelta`) is reported as `internal`, never dropped.
104
130
  - **Rate limits**: `429` surfaces as `status: 'rate-limited'` with `retryAt`;
105
131
  the SDK never hammers and never enforces limits locally (display only).
106
- - **Typed errors** (`AiError.code`): `auth-invalid`, `not-entitled`,
107
- `user-blocked`, `app-unknown`, `rate-limited`, `upstream-busy`,
108
- `payload-too-large`, `modality-not-allowed`, `config-unavailable`,
109
- `upstream-error`, `internal`.
132
+ - **Streamed text** reaches the screen on an interval (50 ms by default), not
133
+ once per token: `useAiChat(client, { flushIntervalMs })` tunes it, and `0`
134
+ renders per token. Whatever had arrived is always flushed with the terminal
135
+ state, so a half-answer stays on screen when a stream fails.
136
+ - **Typed errors** (`AiError.code`): `auth-invalid`, `session-expired`,
137
+ `not-entitled`, `user-blocked`, `app-unknown`, `rate-limited`,
138
+ `upstream-busy`, `payload-too-large`, `modality-not-allowed`,
139
+ `config-unavailable`, `network`, `upstream-error`, `internal`.
140
+ `AiError.cause` carries the code the layer underneath used — the gateway's own
141
+ code, or rn-auth's — so a screen can say *why*. `retryAfterSeconds` is read
142
+ from the body, or from the `Retry-After` header when the body omits it.
110
143
  - **Copy keys**: `ai.*` defaults in `aiMessages` — map through your i18n.
111
144
 
112
145
  ## Public API
@@ -114,13 +147,22 @@ send({
114
147
  Everything is exported from `@chipmobilesdk/rn-ai`; internal `config/`,
115
148
  `transport/`, `core/`, `react/`, and `diagnostics/` paths are not public.
116
149
 
117
- - Client: `createAiClient`, `AiClient`, `AiClientConfig`, `TokenProvider`
150
+ - Client: `createAiClient`, `AiClient`, `AiClientConfig`, `TokenProvider`,
151
+ `AccessTokenRequestOptions`
118
152
  - Chat: `ChatRequest`, `ChatMessage`, `ContentPart`, `ChatStreamHandlers`,
119
153
  `ChatHandle`, `Usage`
120
154
  - Behavior and cache: `AppBehaviorConfig`, `AiBehaviorState`, `BehaviorState`,
121
- `AiConfigCache`, `QuotaSnapshot`
155
+ `AiConfigCache`, `configCacheFromKeyValue`, `SUPPORTED_SCHEMA_VERSION`,
156
+ `QuotaSnapshot`
157
+ - Transport: `StreamTransport` and its `SseRequest` / `SseHandlers` /
158
+ `SseHandle` / `SseResponseInfo` types. `createAiClient({ transport })` takes
159
+ any implementation; the default is an XHR reader.
160
+ - The gateway contract, shared with the service so both sides cannot drift:
161
+ `GatewayErrorCode`, `GatewayErrorBody`, `GatewayQuota`, `GatewayUsage`,
162
+ `GatewayStreamEvent`, `GATEWAY_ERROR_STATUS`, `QUOTA_HEADERS`,
163
+ `SSE_DONE_SENTINEL`
122
164
  - React: `useAiChat`, `useAiQuota`, `useAiBehaviorState`, `AiChatStatus`,
123
- `UseAiChatResult`, `UseAiQuotaResult`. `useAiBehaviorState(client)` re-renders when
165
+ `UseAiChatOptions`, `UseAiChatResult`, `UseAiQuotaResult`. `useAiBehaviorState(client)` re-renders when
124
166
  the config loads or the `aiEnabled` kill switch flips; `client.subscribe` and
125
167
  `client.getBehaviorState()` give the same outside React.
126
168
  - Diagnostics and copy: `AiError`, `AiErrorCode`, `isAiError`, `aiMessages`,
@@ -144,10 +186,6 @@ Everything is exported from `@chipmobilesdk/rn-ai`; internal `config/`,
144
186
  React ≥19 · React Native ≥0.85 (Hermes) · iOS + Android · Node ≥22.11 for
145
187
  tooling.
146
188
 
147
- The repository contains `src/screens/AiDemoScreen.tsx` and its component test.
148
- The current demo sidebar does not expose that screen yet, so it must be wired
149
- into `ScreenKey`, `Sidebar`, and `ActiveScreen` before it is reachable in-app.
150
-
151
189
  ## Validation
152
190
 
153
191
  Run from the repository root:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-ai",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -31,18 +31,49 @@ read it before assuming a behaviour is built in.
31
31
  screen unmounts, otherwise the response resolves against a dead component. Verify
32
32
  teardown on every screen that uses it.
33
33
 
34
+ ## Cache the config, or every launch waits for the network
35
+
36
+ `createAiClient({ storage })` takes an `AiConfigCache`. Without one the behavior config
37
+ lives in memory only, so each cold start blocks the first chat on a network fetch.
38
+ `configCacheFromKeyValue(port)` adapts rn-core's `KeyValuePort` (MMKV through
39
+ `openDurable`), which is how the demo app wires it.
40
+
41
+ ## What the config document is allowed to say
42
+
43
+ `configUrl` and the document's `api.baseUrl` must be https (localhost excepted), the
44
+ document must name the same `appId`, and its `schemaVersion` must be one the installed
45
+ release supports. `allowedHosts` pins the hosts the document may point at. A document
46
+ that breaks any of these is a `config-unavailable` error, not a request sent anyway:
47
+ this is where the app's bearer token would go.
48
+
49
+ ## Errors carry a reason
50
+
51
+ `AiError.code` is what happened; `AiError.cause` is the code underneath (the gateway's,
52
+ or rn-auth's). `network` means the request never arrived and a retry may work;
53
+ `session-expired` means sign-in is needed; `not-entitled` means premium. Use
54
+ `messageKeyForError(error)` for the copy — one key per code, nothing to map by hand.
55
+
34
56
  ## Public API
35
57
 
36
58
  Runtime:
37
59
 
38
- - `createAiClient(...)` — transport-level client
39
- - `useAiChat(...)` → `UseAiChatResult`, with `AiChatStatus` for state
40
- - `useAiQuota(...)` → `UseAiQuotaResult`
60
+ - `createAiClient(...)` — the client: `chat`, `getQuota`, `lastQuota`, `reloadConfig`,
61
+ `getBehaviorState`, `subscribe`
62
+ - `useAiChat(client, { flushIntervalMs })` → `UseAiChatResult` (`send`, `cancel`,
63
+ `status`, `text`, `error`, `retryAt`, `quota`), with `AiChatStatus` for state
64
+ - `useAiQuota(...)` → `UseAiQuotaResult`; `useAiBehaviorState(...)` for the kill switch
65
+ - `configCacheFromKeyValue(port)`, `SUPPORTED_SCHEMA_VERSION`
41
66
  - `AiError`, `isAiError` — narrow before reading `AiErrorCode`
42
- - `aiMessages`, `aiMessage` — user-facing strings keyed by `AiMessageKey`
67
+ - `aiMessages`, `aiMessage`, `aiMessageSet`, `messageKeyForError`
68
+ - `GATEWAY_ERROR_STATUS`, `QUOTA_HEADERS`, `SSE_DONE_SENTINEL` — the gateway contract
43
69
 
44
70
  Use `aiMessage` for anything shown to the user. Never render `AiError.message` directly.
45
71
 
72
+ ## Streamed text is batched
73
+
74
+ `useAiChat` flushes accumulated deltas every 50 ms rather than rendering per token; pass
75
+ `flushIntervalMs: 0` for per-token rendering. Do not add your own buffering on top.
76
+
46
77
  ## Where the detail lives
47
78
 
48
79
  Read one section of `node_modules/@chipmobilesdk/rn-ai/README.md`:
@@ -1,4 +1,4 @@
1
- import { createListenerSet, createSingleFlight } from '@chipmobilesdk/rn-core';
1
+ import { createListenerSet, createSingleFlight, type KeyValuePort } from '@chipmobilesdk/rn-core';
2
2
 
3
3
  import { AiError } from '../diagnostics/errors';
4
4
  import { DEFAULT_AI_TIMEOUTS, withTimeout } from '../transport/timeouts';
@@ -35,18 +35,84 @@ export class MemoryConfigCache implements AiConfigCache {
35
35
  }
36
36
  }
37
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
+
38
57
  export type BehaviorState = 'unloaded' | 'ready-network' | 'ready-cache' | 'unavailable';
39
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
+
40
67
  const numbers = (value: unknown): number[] | undefined =>
41
68
  Array.isArray(value) ? value.filter((n): n is number => Number.isFinite(n)) : undefined;
42
69
 
43
- /** Parse + validate; unknown fields ignored (forward compatibility). */
44
- 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 {
45
111
  let raw: Record<string, unknown>;
46
112
  try {
47
113
  raw = JSON.parse(json) as Record<string, unknown>;
48
114
  } catch {
49
- throw new AiError('config-unavailable', 'behavior config is not valid JSON');
115
+ invalid('is not valid JSON');
50
116
  }
51
117
  const api = raw.api as Partial<AppBehaviorConfig['api']> | undefined;
52
118
  if (
@@ -54,17 +120,42 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
54
120
  typeof api?.baseUrl !== 'string' ||
55
121
  !Array.isArray(api.scopes)
56
122
  ) {
57
- 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}`);
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');
58
140
  }
141
+ if (options.allowedHosts && !options.allowedHosts.includes(hostOf(baseUrl))) {
142
+ invalid(`names a baseUrl outside the allowed hosts (${hostOf(baseUrl)})`);
143
+ }
144
+
59
145
  const recovery = (raw.recovery ?? {}) as Partial<AppBehaviorConfig['recovery']>;
60
146
  const features = (raw.features ?? {}) as Partial<AppBehaviorConfig['features']>;
61
147
  return {
62
- schemaVersion: Number(raw.schemaVersion ?? 1),
148
+ schemaVersion,
63
149
  appId: raw.appId,
64
- configVersion: Number(raw.configVersion ?? 0),
65
- 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
+ ),
66
157
  api: {
67
- baseUrl: api.baseUrl.replace(/\/$/, ''),
158
+ baseUrl,
68
159
  scopes: api.scopes.filter((s): s is string => typeof s === 'string'),
69
160
  supportsStreaming: api.supportsStreaming !== false,
70
161
  },
@@ -73,9 +164,12 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
73
164
  // throw a TypeError inside the XHR error callback, where nothing caught it.
74
165
  recovery: {
75
166
  reloadOnHttpStatus: numbers(recovery.reloadOnHttpStatus) ?? [404, 410],
76
- maxSilentRetries: Number.isFinite(recovery.maxSilentRetries)
77
- ? Number(recovery.maxSilentRetries)
78
- : 1,
167
+ maxSilentRetries: Math.min(
168
+ MAX_SILENT_RETRIES,
169
+ Number.isFinite(recovery.maxSilentRetries)
170
+ ? Math.max(0, Number(recovery.maxSilentRetries))
171
+ : 1,
172
+ ),
79
173
  retryBackoffMs: numbers(recovery.retryBackoffMs) ?? [500, 2000],
80
174
  },
81
175
  features: { aiEnabled: features.aiEnabled !== false },
@@ -96,6 +190,8 @@ export interface BehaviorConfigDeps {
96
190
  now?: () => number;
97
191
  /** Limit on one config fetch, body included. Defaults to 10 s. */
98
192
  timeoutMs?: number;
193
+ /** Hosts the document's `api.baseUrl` may name. */
194
+ allowedHosts?: readonly string[];
99
195
  }
100
196
 
101
197
  /**
@@ -113,7 +209,7 @@ export class BehaviorConfigManager {
113
209
  * chat and a quota call together, and each used to fetch the config itself.
114
210
  */
115
211
  private readonly cacheRead = createSingleFlight<void>();
116
- private readonly networkFetch = createSingleFlight<AppBehaviorConfig | undefined>();
212
+ private readonly networkFetch = createSingleFlight<AppBehaviorConfig>();
117
213
  private currentState: BehaviorState = 'unloaded';
118
214
  private readonly stateListeners = createListenerSet<[]>();
119
215
 
@@ -134,12 +230,24 @@ export class BehaviorConfigManager {
134
230
  }
135
231
 
136
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
+ }
137
238
  this.cacheKey = `rn-ai:config:${deps.appId}`;
138
239
  this.cache = deps.cache ?? new MemoryConfigCache();
139
240
  this.fetchFn = deps.fetchFn ?? fetch;
140
241
  this.now = deps.now ?? Date.now;
141
242
  }
142
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
+
143
251
  private isFresh(envelope: CacheEnvelope, config: AppBehaviorConfig): boolean {
144
252
  return this.now() - envelope.fetchedAt < config.refreshTtlSeconds * 1000;
145
253
  }
@@ -154,44 +262,62 @@ export class BehaviorConfigManager {
154
262
  if (!stored) return;
155
263
  try {
156
264
  const envelope = JSON.parse(stored) as CacheEnvelope;
157
- this.current = { config: parseBehaviorConfig(envelope.configJson), envelope };
265
+ this.current = { config: this.parse(envelope.configJson), envelope };
158
266
  this.setState('ready-cache');
159
267
  } catch {
160
268
  // corrupted cache entries are ignored, not fatal
161
269
  }
162
270
  }
163
271
 
164
- private fetchNetwork(): Promise<AppBehaviorConfig | undefined> {
272
+ private fetchNetwork(): Promise<AppBehaviorConfig> {
165
273
  return this.networkFetch.run(() => this.fetchFromNetwork());
166
274
  }
167
275
 
168
- private async fetchFromNetwork(): Promise<AppBehaviorConfig | undefined> {
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 }> {
169
280
  const headers: Record<string, string> = {};
170
- if (this.current?.envelope.etag) {
171
- 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;
172
284
  }
173
285
  // Bounded: with no cache, a hung fetch used to block every chat and quota call.
174
- const { response, configJson } = await withTimeout(
286
+ return withTimeout(
175
287
  this.deps.timeoutMs ?? DEFAULT_AI_TIMEOUTS.configMs,
176
288
  async signal => {
177
- const res = await this.fetchFn(this.deps.configUrl, { headers, signal });
178
- 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
+ };
179
295
  },
180
296
  () => new AiError('config-unavailable', 'config fetch timed out'),
181
297
  );
182
- if (response.status === 304 && this.current) {
183
- this.current.envelope.fetchedAt = this.now();
184
- await this.persist(this.current.envelope);
185
- this.setState('ready-network');
186
- 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);
187
313
  }
188
- if (!response.ok) {
189
- 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}`);
190
316
  }
191
- const config = parseBehaviorConfig(configJson);
317
+ const config = this.parse(result.text);
192
318
  const envelope: CacheEnvelope = {
193
- configJson,
194
- etag: response.headers.get('etag') ?? undefined,
319
+ configJson: result.text,
320
+ etag: result.etag,
195
321
  fetchedAt: this.now(),
196
322
  };
197
323
  this.current = { config, envelope };
@@ -218,16 +344,11 @@ export class BehaviorConfigManager {
218
344
  return this.current.config;
219
345
  }
220
346
  try {
221
- const fetched = await this.fetchNetwork();
222
- if (fetched) return fetched;
347
+ return await this.fetchNetwork();
223
348
  } catch (e) {
224
349
  if (this.current) return this.current.config; // stale beats broken
225
350
  this.setState('unavailable');
226
351
  throw e instanceof AiError ? e : new AiError('config-unavailable', String(e));
227
352
  }
228
- // fetchNetwork only returns undefined when a 304 raced an empty cache.
229
- if (this.current) return (this.current as { config: AppBehaviorConfig }).config;
230
- this.setState('unavailable');
231
- throw new AiError('config-unavailable', 'no configuration available');
232
353
  }
233
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';