@chipmobilesdk/rn-ai 0.1.1 → 0.2.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,40 @@
1
1
  # Changelog — @chipmobilesdk/rn-ai
2
2
 
3
+ ## [0.2.0] — 2026-11-01
4
+
5
+ Fixes a chat or quota call that could hang forever, and errors that never
6
+ reached the app. **No code change is required.**
7
+
8
+ ### Fixed
9
+
10
+ - **Every network call has a time limit.** The chat stream had none, and
11
+ neither did the config or quota fetch, so on a stalled mobile connection
12
+ `useAiChat` stayed in `loading`/`streaming` and `useAiQuota` in `loading`
13
+ until the user gave up — and with no cached config, one hung fetch blocked
14
+ every call. New `timeouts: { connectMs, idleMs, configMs, quotaMs }` option
15
+ (defaults 30 s, 30 s, 10 s, 10 s). The idle limit restarts on every chunk.
16
+ - **No error is swallowed.** Every chat path ended in `.catch(() => undefined)`,
17
+ so a transport that threw (an invalid URL from remote config, for example)
18
+ left the UI in `loading` with nothing to show. Such failures now arrive as
19
+ `AiError('internal')`. The remote `recovery` block is type-checked when
20
+ parsed, so a mistyped value can no longer throw inside the XHR callback.
21
+ - **A cancelled or replaced request stays silent.** A token or config failure
22
+ that landed after `cancel()` (or after the next `send()`) still called
23
+ `onError` and flipped that screen to `error`. The client now routes every
24
+ callback through one guard (nothing after cancel, exactly one of
25
+ `onDone`/`onError`), and `useAiChat` and `useAiQuota` drop callbacks from
26
+ older requests.
27
+
28
+ ### Changed
29
+
30
+ - A stalled stream, a timed-out quota fetch and a dropped connection (status 0)
31
+ report `upstream-error` with `recoverable: true`; status 0 was
32
+ `recoverable: false`.
33
+
34
+ ### Added
35
+
36
+ - `AiClientConfig.timeouts` and the `AiTimeouts` type.
37
+
3
38
  ## 0.1.0 — 2026-07-05
4
39
 
5
40
  Initial release (feature `005-azure-ai-gateway`).
package/README.md CHANGED
@@ -3,14 +3,14 @@
3
3
  AI gateway client for ChipMobileSdk-family React Native apps. Streams
4
4
  multimodal (text + image + audio in, text out) AI responses through the
5
5
  platform gateway with per-user rate limiting — apps never talk to Azure
6
- OpenAI directly and hold no AI credentials.
7
-
8
- > **License:** `UNLICENSED`. Published publicly for use by the owner's
9
- > applications; no open-source license is granted.
10
-
11
- - **Version**: `0.1.0`
12
- - **Entrypoint**: `@chipmobilesdk/rn-ai`
13
- - **Native code**: none
6
+ OpenAI directly and hold no AI credentials.
7
+
8
+ > **License:** `UNLICENSED`. Published publicly for use by the owner's
9
+ > applications; no open-source license is granted.
10
+
11
+ - **Version**: `0.2.x` (see [CHANGELOG](CHANGELOG.md))
12
+ - **Entrypoint**: `@chipmobilesdk/rn-ai`
13
+ - **Native code**: none
14
14
 
15
15
  - Contract: [rn-ai-public-api](../../specs/005-azure-ai-gateway/contracts/rn-ai-public-api.md)
16
16
  - Gateway API: [gateway-api](../../specs/005-azure-ai-gateway/contracts/gateway-api.md)
@@ -53,12 +53,12 @@ export const aiClient = createAiClient({
53
53
 
54
54
  3. Use the hooks:
55
55
 
56
- ```tsx
57
- import { useAiChat, useAiQuota } from '@chipmobilesdk/rn-ai';
58
- import { aiClient } from './ai/setup';
59
-
60
- const { send, cancel, status, text, error, retryAt } = useAiChat(aiClient);
61
- const { quota } = useAiQuota(aiClient);
56
+ ```tsx
57
+ import { useAiChat, useAiQuota } from '@chipmobilesdk/rn-ai';
58
+ import { aiClient } from './ai/setup';
59
+
60
+ const { send, cancel, status, text, error, retryAt } = useAiChat(aiClient);
61
+ const { quota } = useAiQuota(aiClient);
62
62
 
63
63
  send({
64
64
  messages: [{
@@ -71,37 +71,54 @@ send({
71
71
  });
72
72
  ```
73
73
 
74
- ## Runtime behavior
75
-
76
- - **Config-driven endpoint**: resolved lazily on the first quota or chat
77
- request. A fresh cache entry is used immediately; a stale entry is used
78
- immediately and revalidated in the background with its ETag. With no cache,
79
- the first operation waits for the config request. Designated `404`/`410`
80
- responses invalidate config and retry against the refreshed endpoint.
81
- - **Auth**: acquires tokens via rn-auth (`requestAccessToken('ai-gateway')`);
82
- on `401`, reacquires a token and retries once.
74
+ ## Runtime behavior
75
+
76
+ - **Config-driven endpoint**: resolved lazily on the first quota or chat
77
+ request. A fresh cache entry is used immediately; a stale entry is used
78
+ immediately and revalidated in the background with its ETag. With no cache,
79
+ the first operation waits for the config request, for at most `configMs`.
80
+ Designated `404`/`410`
81
+ responses invalidate config and retry against the refreshed endpoint.
82
+ - **Auth**: acquires tokens via rn-auth (`requestAccessToken('ai-gateway')`);
83
+ on `401`, reacquires a token and retries once.
84
+ - **Timeouts**: every network call is bounded. Override any of them with
85
+ `createAiClient({ timeouts: { ... } })`:
86
+
87
+ | | Default | Fails with |
88
+ |---|---|---|
89
+ | `connectMs` — chat request sent → headers | 30 s | `upstream-error`, `recoverable: true` |
90
+ | `idleMs` — silence between two stream chunks | 30 s | `upstream-error`, `recoverable: true` |
91
+ | `configMs` — behavior-config fetch | 10 s | stale cache if any, else `config-unavailable` |
92
+ | `quotaMs` — quota fetch | 10 s | `upstream-error`, `recoverable: true` |
93
+
94
+ The gateway sends headers only after the model answers, so `connectMs` also
95
+ covers time to first token. A dropped connection (HTTP status 0) is likewise
96
+ `upstream-error` with `recoverable: true`.
97
+ - **Callbacks**: each `chat()` delivers exactly one of `onDone` / `onError`,
98
+ and nothing after `cancel()`. An unexpected exception anywhere on the way
99
+ (including in your own `onDelta`) is reported as `internal`, never dropped.
83
100
  - **Rate limits**: `429` surfaces as `status: 'rate-limited'` with `retryAt`;
84
101
  the SDK never hammers and never enforces limits locally (display only).
85
102
  - **Typed errors** (`AiError.code`): `auth-invalid`, `not-entitled`,
86
103
  `user-blocked`, `app-unknown`, `rate-limited`, `upstream-busy`,
87
104
  `payload-too-large`, `modality-not-allowed`, `config-unavailable`,
88
105
  `upstream-error`, `internal`.
89
- - **Copy keys**: `ai.*` defaults in `aiMessages` — map through your i18n.
90
-
91
- ## Public API
92
-
93
- Everything is exported from `@chipmobilesdk/rn-ai`; internal `config/`,
94
- `transport/`, `core/`, `react/`, and `diagnostics/` paths are not public.
95
-
96
- - Client: `createAiClient`, `AiClient`, `AiClientConfig`, `TokenProvider`
97
- - Chat: `ChatRequest`, `ChatMessage`, `ContentPart`, `ChatStreamHandlers`,
98
- `ChatHandle`, `Usage`
99
- - Behavior and cache: `AppBehaviorConfig`, `AiBehaviorState`, `BehaviorState`,
100
- `AiConfigCache`, `QuotaSnapshot`
101
- - React: `useAiChat`, `useAiQuota`, `AiChatStatus`, `UseAiChatResult`,
102
- `UseAiQuotaResult`
103
- - Diagnostics and copy: `AiError`, `AiErrorCode`, `isAiError`, `aiMessages`,
104
- `aiMessage`, `AiMessageKey`
106
+ - **Copy keys**: `ai.*` defaults in `aiMessages` — map through your i18n.
107
+
108
+ ## Public API
109
+
110
+ Everything is exported from `@chipmobilesdk/rn-ai`; internal `config/`,
111
+ `transport/`, `core/`, `react/`, and `diagnostics/` paths are not public.
112
+
113
+ - Client: `createAiClient`, `AiClient`, `AiClientConfig`, `TokenProvider`
114
+ - Chat: `ChatRequest`, `ChatMessage`, `ContentPart`, `ChatStreamHandlers`,
115
+ `ChatHandle`, `Usage`
116
+ - Behavior and cache: `AppBehaviorConfig`, `AiBehaviorState`, `BehaviorState`,
117
+ `AiConfigCache`, `QuotaSnapshot`
118
+ - React: `useAiChat`, `useAiQuota`, `AiChatStatus`, `UseAiChatResult`,
119
+ `UseAiQuotaResult`
120
+ - Diagnostics and copy: `AiError`, `AiErrorCode`, `isAiError`, `aiMessages`,
121
+ `aiMessage`, `AiMessageKey`
105
122
 
106
123
  ## App responsibilities
107
124
 
@@ -114,21 +131,21 @@ Everything is exported from `@chipmobilesdk/rn-ai`; internal `config/`,
114
131
  - Respect `payload-too-large` (default cap 10 MB/request) by resizing images
115
132
  and bounding audio length.
116
133
 
117
- ## Compatibility
118
-
119
- React ≥19 · React Native ≥0.85 (Hermes) · iOS + Android · Node ≥22.11 for
120
- tooling.
121
-
122
- The repository contains `src/screens/AiDemoScreen.tsx` and its component test.
123
- The current demo sidebar does not expose that screen yet, so it must be wired
124
- into `ScreenKey`, `Sidebar`, and `ActiveScreen` before it is reachable in-app.
125
-
126
- ## Validation
127
-
128
- Run from the repository root:
129
-
130
- ```bash
131
- npm run typecheck:ai
132
- npm run pack:ai
133
- npm run validate:ai
134
- ```
134
+ ## Compatibility
135
+
136
+ React ≥19 · React Native ≥0.85 (Hermes) · iOS + Android · Node ≥22.11 for
137
+ tooling.
138
+
139
+ The repository contains `src/screens/AiDemoScreen.tsx` and its component test.
140
+ The current demo sidebar does not expose that screen yet, so it must be wired
141
+ into `ScreenKey`, `Sidebar`, and `ActiveScreen` before it is reachable in-app.
142
+
143
+ ## Validation
144
+
145
+ Run from the repository root:
146
+
147
+ ```bash
148
+ npm run typecheck:ai
149
+ npm run pack:ai
150
+ npm run validate:ai
151
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-ai",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -1,57 +1,57 @@
1
- ---
2
- name: sdk-rn-ai
3
- description: "AI chat and quota via @chipmobilesdk/rn-ai: createAiClient, useAiChat, useAiQuota, streaming status, AI error codes. Use when adding an AI chat feature, showing quota, or handling AI request failures and cancellation."
4
- metadata:
5
- package: "@chipmobilesdk/rn-ai"
6
- packageVersion: "0.1.0"
7
- user-invocable: true
8
- disable-model-invocation: false
9
- ---
10
-
11
- # @chipmobilesdk/rn-ai
12
-
13
- Client and React hooks for AI chat backed by the gateway, including quota reporting
14
- and a typed error model.
15
-
16
- ## Use this when
17
-
18
- - Adding an AI chat screen or message composer
19
- - Displaying remaining quota
20
- - Handling AI request failure, cancellation, or streaming state
21
- - Localising AI-related user messages
22
-
23
- ## The app owns more than you expect
24
-
25
- This package deliberately does not handle everything. The README has an
26
- `## App responsibilities` section listing what the consuming app must implement —
27
- read it before assuming a behaviour is built in.
28
-
29
- ## Cancellation is required, not optional
30
-
31
- `useAiChat` exposes streaming status. An in-flight request MUST be cancelled when the
32
- screen unmounts, otherwise the response resolves against a dead component. Verify
33
- teardown on every screen that uses it.
34
-
35
- ## Public API (v0.1.0)
36
-
37
- Runtime:
38
-
39
- - `createAiClient(...)` — transport-level client
40
- - `useAiChat(...)` → `UseAiChatResult`, with `AiChatStatus` for state
41
- - `useAiQuota(...)` → `UseAiQuotaResult`
42
- - `AiError`, `isAiError` — narrow before reading `AiErrorCode`
43
- - `aiMessages`, `aiMessage` — user-facing strings keyed by `AiMessageKey`
44
-
45
- Use `aiMessage` for anything shown to the user. Never render `AiError.message` directly.
46
-
47
- ## Where the detail lives
48
-
49
- Read one section of `node_modules/@chipmobilesdk/rn-ai/README.md`:
50
-
51
- | Need | Section |
52
- |---|---|
53
- | First integration | `## Install`, `## Setup` |
54
- | Streaming, retries, timeouts | `## Runtime behavior` |
55
- | Full export list | `## Public API` |
56
- | What the app must implement | `## App responsibilities` |
57
- | Version/peer constraints | `## Compatibility` |
1
+ ---
2
+ name: sdk-rn-ai
3
+ description: "AI chat and quota via @chipmobilesdk/rn-ai: createAiClient, useAiChat, useAiQuota, streaming status, AI error codes. Use when adding an AI chat feature, showing quota, or handling AI request failures and cancellation."
4
+ metadata:
5
+ package: "@chipmobilesdk/rn-ai"
6
+ packageVersion: "0.1.0"
7
+ user-invocable: true
8
+ disable-model-invocation: false
9
+ ---
10
+
11
+ # @chipmobilesdk/rn-ai
12
+
13
+ Client and React hooks for AI chat backed by the gateway, including quota reporting
14
+ and a typed error model.
15
+
16
+ ## Use this when
17
+
18
+ - Adding an AI chat screen or message composer
19
+ - Displaying remaining quota
20
+ - Handling AI request failure, cancellation, or streaming state
21
+ - Localising AI-related user messages
22
+
23
+ ## The app owns more than you expect
24
+
25
+ This package deliberately does not handle everything. The README has an
26
+ `## App responsibilities` section listing what the consuming app must implement —
27
+ read it before assuming a behaviour is built in.
28
+
29
+ ## Cancellation is required, not optional
30
+
31
+ `useAiChat` exposes streaming status. An in-flight request MUST be cancelled when the
32
+ screen unmounts, otherwise the response resolves against a dead component. Verify
33
+ teardown on every screen that uses it.
34
+
35
+ ## Public API (v0.2.0)
36
+
37
+ Runtime:
38
+
39
+ - `createAiClient(...)` — transport-level client
40
+ - `useAiChat(...)` → `UseAiChatResult`, with `AiChatStatus` for state
41
+ - `useAiQuota(...)` → `UseAiQuotaResult`
42
+ - `AiError`, `isAiError` — narrow before reading `AiErrorCode`
43
+ - `aiMessages`, `aiMessage` — user-facing strings keyed by `AiMessageKey`
44
+
45
+ Use `aiMessage` for anything shown to the user. Never render `AiError.message` directly.
46
+
47
+ ## Where the detail lives
48
+
49
+ Read one section of `node_modules/@chipmobilesdk/rn-ai/README.md`:
50
+
51
+ | Need | Section |
52
+ |---|---|
53
+ | First integration | `## Install`, `## Setup` |
54
+ | Streaming, retries, timeouts | `## Runtime behavior` |
55
+ | Full export list | `## Public API` |
56
+ | What the app must implement | `## App responsibilities` |
57
+ | Version/peer constraints | `## Compatibility` |
@@ -1,4 +1,5 @@
1
1
  import { AiError } from '../diagnostics/errors';
2
+ import { DEFAULT_AI_TIMEOUTS, withTimeout } from '../transport/timeouts';
2
3
 
3
4
  /** App behavior configuration (contracts/app-behavior-config.md). */
4
5
  export interface AppBehaviorConfig {
@@ -34,6 +35,9 @@ export class MemoryConfigCache implements AiConfigCache {
34
35
 
35
36
  export type BehaviorState = 'unloaded' | 'ready-network' | 'ready-cache' | 'unavailable';
36
37
 
38
+ const numbers = (value: unknown): number[] | undefined =>
39
+ Array.isArray(value) ? value.filter((n): n is number => Number.isFinite(n)) : undefined;
40
+
37
41
  /** Parse + validate; unknown fields ignored (forward compatibility). */
38
42
  export function parseBehaviorConfig(json: string): AppBehaviorConfig {
39
43
  let raw: Record<string, unknown>;
@@ -63,10 +67,14 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
63
67
  supportsStreaming: api.supportsStreaming !== false,
64
68
  },
65
69
  limitsDisplay: raw.limitsDisplay as AppBehaviorConfig['limitsDisplay'],
70
+ // Typed here, once: a remote document with `reloadOnHttpStatus: "404"` used to
71
+ // throw a TypeError inside the XHR error callback, where nothing caught it.
66
72
  recovery: {
67
- reloadOnHttpStatus: recovery.reloadOnHttpStatus ?? [404, 410],
68
- maxSilentRetries: recovery.maxSilentRetries ?? 1,
69
- retryBackoffMs: recovery.retryBackoffMs ?? [500, 2000],
73
+ reloadOnHttpStatus: numbers(recovery.reloadOnHttpStatus) ?? [404, 410],
74
+ maxSilentRetries: Number.isFinite(recovery.maxSilentRetries)
75
+ ? Number(recovery.maxSilentRetries)
76
+ : 1,
77
+ retryBackoffMs: numbers(recovery.retryBackoffMs) ?? [500, 2000],
70
78
  },
71
79
  features: { aiEnabled: features.aiEnabled !== false },
72
80
  };
@@ -84,6 +92,8 @@ export interface BehaviorConfigDeps {
84
92
  cache?: AiConfigCache;
85
93
  fetchFn?: typeof fetch;
86
94
  now?: () => number;
95
+ /** Limit on one config fetch, body included. Defaults to 10 s. */
96
+ timeoutMs?: number;
87
97
  }
88
98
 
89
99
  /**
@@ -127,7 +137,15 @@ export class BehaviorConfigManager {
127
137
  if (this.current?.envelope.etag) {
128
138
  headers['If-None-Match'] = this.current.envelope.etag;
129
139
  }
130
- const response = await this.fetchFn(this.deps.configUrl, { headers });
140
+ // Bounded: with no cache, a hung fetch used to block every chat and quota call.
141
+ const { response, configJson } = await withTimeout(
142
+ this.deps.timeoutMs ?? DEFAULT_AI_TIMEOUTS.configMs,
143
+ 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() : '' };
146
+ },
147
+ () => new AiError('config-unavailable', 'config fetch timed out'),
148
+ );
131
149
  if (response.status === 304 && this.current) {
132
150
  this.current.envelope.fetchedAt = this.now();
133
151
  await this.persist(this.current.envelope);
@@ -137,7 +155,6 @@ export class BehaviorConfigManager {
137
155
  if (!response.ok) {
138
156
  throw new AiError('config-unavailable', `config fetch failed: ${response.status}`);
139
157
  }
140
- const configJson = await response.text();
141
158
  const config = parseBehaviorConfig(configJson);
142
159
  const envelope: CacheEnvelope = {
143
160
  configJson,
@@ -6,6 +6,7 @@ import {
6
6
  } from '../config/behaviorConfig';
7
7
  import { AiError, errorFromHttp } from '../diagnostics/errors';
8
8
  import { requestSse, type SseHandle } from '../transport/sseClient';
9
+ import { DEFAULT_AI_TIMEOUTS, withTimeout, type AiTimeouts } from '../transport/timeouts';
9
10
 
10
11
  /** Minimal slice of the rn-auth `Auth` controller the client needs. */
11
12
  export interface TokenProvider {
@@ -66,6 +67,8 @@ export interface AiClientConfig {
66
67
  fetchFn?: typeof fetch;
67
68
  /** Test seam for the SSE transport. */
68
69
  transport?: typeof requestSse;
70
+ /** Overrides for the network time limits; see `AiTimeouts` for the defaults. */
71
+ timeouts?: Partial<AiTimeouts>;
69
72
  }
70
73
 
71
74
  export interface AiClient {
@@ -87,11 +90,13 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
87
90
  const resourceKey = clientConfig.resourceKey ?? 'ai-gateway';
88
91
  const transport = clientConfig.transport ?? requestSse;
89
92
  const fetchFn = clientConfig.fetchFn ?? fetch;
93
+ const timeouts: AiTimeouts = { ...DEFAULT_AI_TIMEOUTS, ...clientConfig.timeouts };
90
94
  const configManager = new BehaviorConfigManager({
91
95
  configUrl: clientConfig.configUrl,
92
96
  appId: clientConfig.appId,
93
97
  cache: clientConfig.storage,
94
98
  fetchFn: clientConfig.fetchFn,
99
+ timeoutMs: timeouts.configMs,
95
100
  });
96
101
  let lastConfig: AppBehaviorConfig | undefined;
97
102
 
@@ -115,17 +120,51 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
115
120
 
116
121
  function chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle {
117
122
  let cancelled = false;
123
+ let settled = false;
118
124
  let inner: SseHandle | undefined;
119
125
 
126
+ // Every callback to the app goes through here. Nothing is delivered after
127
+ // cancel() — an acquire() or config reload that failed after the user
128
+ // cancelled (or sent the next message) used to flip that screen to
129
+ // `error` — and exactly one terminal callback is delivered.
130
+ const emit = {
131
+ delta(text: string) {
132
+ if (!cancelled && !settled) handlers.onDelta(text);
133
+ },
134
+ done(result: { usage: Usage; quota?: QuotaSnapshot }) {
135
+ if (cancelled || settled) return;
136
+ settled = true;
137
+ handlers.onDone(result);
138
+ },
139
+ error(error: AiError) {
140
+ if (cancelled || settled) return;
141
+ settled = true;
142
+ inner?.cancel();
143
+ handlers.onError(error);
144
+ },
145
+ };
146
+
147
+ // Any exception on the way becomes a reported error. Each path used to end
148
+ // in `.catch(() => undefined)`, so a transport that threw (a bad URL from
149
+ // remote config, say) left the UI in `loading` with nothing to show.
150
+ const fail = (e: unknown) =>
151
+ emit.error(
152
+ e instanceof AiError
153
+ ? e
154
+ : new AiError('internal', e instanceof Error ? e.message : String(e)),
155
+ );
156
+ const guarded =
157
+ <A extends unknown[]>(fn: (...args: A) => void) =>
158
+ (...args: A) => {
159
+ try {
160
+ fn(...args);
161
+ } catch (e) {
162
+ fail(e);
163
+ }
164
+ };
165
+
120
166
  const attempt = async (state: { configReloads: number; authRetries: number }) => {
121
- let config: AppBehaviorConfig;
122
- let token: string;
123
- try {
124
- ({ config, token } = await acquire());
125
- } catch (e) {
126
- handlers.onError(e instanceof AiError ? e : new AiError('internal', String(e)));
127
- return;
128
- }
167
+ const { config, token } = await acquire();
129
168
  if (cancelled) return;
130
169
 
131
170
  let done: { usage: Usage; quota?: QuotaSnapshot } | undefined;
@@ -139,9 +178,11 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
139
178
  'Content-Type': 'application/json',
140
179
  },
141
180
  body: JSON.stringify(request),
181
+ connectTimeoutMs: timeouts.connectMs,
182
+ idleTimeoutMs: timeouts.idleMs,
142
183
  },
143
184
  {
144
- onData: payload => {
185
+ onData: guarded(payload => {
145
186
  if (payload === '[DONE]') return;
146
187
  let event: StreamEvent;
147
188
  try {
@@ -150,52 +191,56 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
150
191
  return;
151
192
  }
152
193
  if (event.type === 'delta' && typeof event.text === 'string') {
153
- handlers.onDelta(event.text);
194
+ emit.delta(event.text);
154
195
  } else if (event.type === 'done' && event.usage) {
155
196
  done = { usage: event.usage, quota: event.quota };
156
197
  } else if (event.type === 'error') {
157
198
  streamErrorCode = event.code ?? 'UPSTREAM_ERROR';
158
199
  }
159
- },
160
- onEnd: () => {
161
- if (cancelled) return;
200
+ }),
201
+ onEnd: guarded(() => {
162
202
  if (streamErrorCode) {
163
- handlers.onError(new AiError('upstream-error', 'the AI service failed mid-response'));
203
+ emit.error(new AiError('upstream-error', 'the AI service failed mid-response'));
164
204
  } else if (done) {
165
- handlers.onDone(done);
205
+ emit.done(done);
166
206
  } else {
167
- handlers.onError(new AiError('internal', 'stream ended without completion'));
207
+ emit.error(new AiError('internal', 'stream ended without completion'));
168
208
  }
169
- },
170
- onError: (status, bodyText) => {
209
+ }),
210
+ onError: guarded((status, bodyText) => {
171
211
  if (cancelled) return;
212
+ if (status === 0 && bodyText === 'timeout') {
213
+ emit.error(
214
+ new AiError('upstream-error', 'the AI service stopped responding', {
215
+ recoverable: true,
216
+ }),
217
+ );
218
+ return;
219
+ }
172
220
  // Designated config-stale signals → silent reload + retry once (FR-018).
173
221
  if (
174
222
  config.recovery.reloadOnHttpStatus.includes(status) &&
175
223
  state.configReloads < config.recovery.maxSilentRetries
176
224
  ) {
177
- configManager
178
- .getConfig({ forceReload: true })
179
- .then(() => attempt({ ...state, configReloads: state.configReloads + 1 }))
180
- .catch(e =>
181
- handlers.onError(
182
- e instanceof AiError ? e : new AiError('config-unavailable', String(e)),
183
- ),
184
- );
225
+ configManager.getConfig({ forceReload: true }).then(
226
+ () => attempt({ ...state, configReloads: state.configReloads + 1 }).catch(fail),
227
+ e =>
228
+ fail(e instanceof AiError ? e : new AiError('config-unavailable', String(e))),
229
+ );
185
230
  return;
186
231
  }
187
232
  // Expired server-side session → one fresh-token retry.
188
233
  if (status === 401 && state.authRetries < 1) {
189
- attempt({ ...state, authRetries: state.authRetries + 1 }).catch(() => undefined);
234
+ attempt({ ...state, authRetries: state.authRetries + 1 }).catch(fail);
190
235
  return;
191
236
  }
192
- handlers.onError(errorFromHttp(status, bodyText));
193
- },
237
+ emit.error(errorFromHttp(status, bodyText));
238
+ }),
194
239
  },
195
240
  );
196
241
  };
197
242
 
198
- attempt({ configReloads: 0, authRetries: 0 }).catch(() => undefined);
243
+ attempt({ configReloads: 0, authRetries: 0 }).catch(fail);
199
244
  return {
200
245
  cancel() {
201
246
  cancelled = true;
@@ -206,14 +251,20 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
206
251
 
207
252
  async function getQuota(): Promise<QuotaSnapshot> {
208
253
  const { config, token } = await acquire();
209
- const response = await fetchFn(
210
- `${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/quota`,
211
- { headers: { Authorization: `Bearer ${token}` } },
254
+ return withTimeout(
255
+ timeouts.quotaMs,
256
+ async signal => {
257
+ const response = await fetchFn(
258
+ `${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/quota`,
259
+ { headers: { Authorization: `Bearer ${token}` }, signal },
260
+ );
261
+ if (!response.ok) {
262
+ throw errorFromHttp(response.status, await response.text());
263
+ }
264
+ return (await response.json()) as QuotaSnapshot;
265
+ },
266
+ () => new AiError('upstream-error', 'quota request timed out', { recoverable: true }),
212
267
  );
213
- if (!response.ok) {
214
- throw errorFromHttp(response.status, await response.text());
215
- }
216
- return (await response.json()) as QuotaSnapshot;
217
268
  }
218
269
 
219
270
  return {
@@ -67,5 +67,9 @@ export function errorFromHttp(status: number, bodyText: string): AiError {
67
67
  message = 'network failure';
68
68
  }
69
69
  }
70
- return new AiError(code, message, { retryAfterSeconds });
70
+ // Status 0 is a dropped connection, which a retry may well fix.
71
+ return new AiError(code, message, {
72
+ retryAfterSeconds,
73
+ recoverable: status === 0 ? true : undefined,
74
+ });
71
75
  }
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export type {
25
25
  BehaviorState,
26
26
  } from './config/behaviorConfig';
27
27
 
28
+ export type { AiTimeouts } from './transport/timeouts';
28
29
  export { useAiChat, useAiQuota } from './react/hooks';
29
30
  export type { AiChatStatus, UseAiChatResult, UseAiQuotaResult } from './react/hooks';
30
31
 
@@ -33,24 +33,33 @@ export function useAiChat(client: AiClient): UseAiChatResult {
33
33
  const [retryAt, setRetryAt] = useState<Date | undefined>();
34
34
  const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
35
35
  const handleRef = useRef<ChatHandle | null>(null);
36
+ // Bumped by every send, cancel and unmount. A callback from an older request
37
+ // is dropped, so it can never overwrite the state of the one on screen —
38
+ // defence in depth behind the client's own cancel guard.
39
+ const requestRef = useRef(0);
36
40
 
37
41
  const send = useCallback(
38
42
  (request: ChatRequest) => {
39
43
  handleRef.current?.cancel();
44
+ const id = ++requestRef.current;
45
+ const current = () => requestRef.current === id;
40
46
  setText('');
41
47
  setError(undefined);
42
48
  setRetryAt(undefined);
43
49
  setStatus('loading');
44
50
  handleRef.current = client.chat(request, {
45
51
  onDelta: delta => {
52
+ if (!current()) return;
46
53
  setStatus('streaming');
47
54
  setText(previous => previous + delta);
48
55
  },
49
56
  onDone: result => {
57
+ if (!current()) return;
50
58
  if (result.quota) setQuota(result.quota);
51
59
  setStatus('done');
52
60
  },
53
61
  onError: aiError => {
62
+ if (!current()) return;
54
63
  setError(aiError);
55
64
  if (aiError.code === 'rate-limited') {
56
65
  setStatus('rate-limited');
@@ -67,11 +76,18 @@ export function useAiChat(client: AiClient): UseAiChatResult {
67
76
  );
68
77
 
69
78
  const cancel = useCallback(() => {
79
+ requestRef.current += 1;
70
80
  handleRef.current?.cancel();
71
81
  setStatus('idle');
72
82
  }, []);
73
83
 
74
- useEffect(() => () => handleRef.current?.cancel(), []);
84
+ useEffect(
85
+ () => () => {
86
+ requestRef.current += 1;
87
+ handleRef.current?.cancel();
88
+ },
89
+ [],
90
+ );
75
91
 
76
92
  return { send, cancel, status, text, error, retryAt, quota };
77
93
  }
@@ -86,18 +102,23 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
86
102
  const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
87
103
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
88
104
  const alive = useRef(true);
105
+ // Only the latest refresh may settle the state; an older, slower one that
106
+ // lands afterwards is stale.
107
+ const latest = useRef(0);
89
108
 
90
109
  const refresh = useCallback(() => {
110
+ const id = ++latest.current;
111
+ const current = () => alive.current && latest.current === id;
91
112
  setStatus('loading');
92
113
  client
93
114
  .getQuota()
94
115
  .then(snapshot => {
95
- if (!alive.current) return;
116
+ if (!current()) return;
96
117
  setQuota(snapshot);
97
118
  setStatus('ready');
98
119
  })
99
120
  .catch(() => {
100
- if (alive.current) setStatus('error');
121
+ if (current()) setStatus('error');
101
122
  });
102
123
  }, [client]);
103
124
 
@@ -14,7 +14,12 @@ export interface SseRequest {
14
14
  headers?: Record<string, string>;
15
15
  /** JSON string body for POST requests. */
16
16
  body?: string;
17
+ /** Hard limit on the whole request. Rarely right for a stream; prefer the two below. */
17
18
  timeoutMs?: number;
19
+ /** Fails with `onError(0, 'timeout')` if response headers have not arrived by then. */
20
+ connectTimeoutMs?: number;
21
+ /** Fails with `onError(0, 'timeout')` when no chunk arrives for this long. Reset per chunk. */
22
+ idleTimeoutMs?: number;
18
23
  }
19
24
 
20
25
  export interface SseHandlers {
@@ -56,15 +61,44 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
56
61
  let finished = false;
57
62
  let cancelled = false;
58
63
 
64
+ // One timer, re-armed as the request progresses: the connect limit until
65
+ // headers arrive, then the idle limit, restarted by every chunk.
66
+ let stallTimer: ReturnType<typeof setTimeout> | undefined;
67
+ const disarm = () => {
68
+ if (stallTimer !== undefined) {
69
+ clearTimeout(stallTimer);
70
+ stallTimer = undefined;
71
+ }
72
+ };
73
+
59
74
  const finish = (fn: () => void) => {
75
+ disarm();
60
76
  if (!finished && !cancelled) {
61
77
  finished = true;
62
78
  fn();
63
79
  }
64
80
  };
65
81
 
82
+ const stalled = () => {
83
+ stallTimer = undefined;
84
+ if (finished || cancelled) return;
85
+ finished = true;
86
+ try {
87
+ xhr.abort();
88
+ } catch {
89
+ // already settled
90
+ }
91
+ handlers.onError(0, 'timeout');
92
+ };
93
+
94
+ const arm = (ms: number | undefined) => {
95
+ disarm();
96
+ if (ms && !finished && !cancelled) stallTimer = setTimeout(stalled, ms);
97
+ };
98
+
66
99
  const pump = () => {
67
100
  if (xhr.status !== 200) return; // error path handled on load/error
101
+ arm(request.idleTimeoutMs);
68
102
  const text: string = xhr.responseText ?? '';
69
103
  seen = drainSseEvents(text, seen, handlers.onData);
70
104
  };
@@ -78,6 +112,7 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
78
112
 
79
113
  xhr.onprogress = pump;
80
114
  xhr.onreadystatechange = () => {
115
+ if (xhr.readyState === 2) arm(request.idleTimeoutMs); // HEADERS_RECEIVED
81
116
  if (xhr.readyState === 3) pump(); // LOADING: incremental text
82
117
  };
83
118
  xhr.onload = () => {
@@ -92,10 +127,12 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
92
127
  xhr.ontimeout = () => finish(() => handlers.onError(0, 'timeout'));
93
128
 
94
129
  xhr.send(request.body);
130
+ arm(request.connectTimeoutMs);
95
131
 
96
132
  return {
97
133
  cancel() {
98
134
  cancelled = true;
135
+ disarm();
99
136
  try {
100
137
  xhr.abort();
101
138
  } catch {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Time limits for every network call the client makes (AI-04).
3
+ *
4
+ * Without them a stalled mobile connection left `useAiChat` in `loading` or
5
+ * `streaming`, and `useAiQuota` in `loading`, until the user gave up.
6
+ */
7
+
8
+ export interface AiTimeouts {
9
+ /** Request sent → response headers received, for the chat stream. */
10
+ connectMs: number;
11
+ /** Longest silence allowed between two chunks of the chat stream. */
12
+ idleMs: number;
13
+ /** Behavior-config fetch, including reading the body. */
14
+ configMs: number;
15
+ /** Quota fetch, including reading the body. */
16
+ quotaMs: number;
17
+ }
18
+
19
+ /**
20
+ * The gateway sends headers only once the model has answered, so `connectMs`
21
+ * also covers time to first token on a large image prompt.
22
+ */
23
+ export const DEFAULT_AI_TIMEOUTS: AiTimeouts = {
24
+ connectMs: 30_000,
25
+ idleMs: 30_000,
26
+ configMs: 10_000,
27
+ quotaMs: 10_000,
28
+ };
29
+
30
+ /**
31
+ * Runs `run` with an abort signal and rejects with `onTimeout()` after `ms`.
32
+ * The race matters as much as the signal: an injected `fetchFn` (or an old
33
+ * polyfill) may ignore the signal entirely.
34
+ */
35
+ export async function withTimeout<T>(
36
+ ms: number,
37
+ run: (signal: AbortSignal | undefined) => Promise<T>,
38
+ onTimeout: () => Error,
39
+ ): Promise<T> {
40
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController();
41
+ let timer: ReturnType<typeof setTimeout> | undefined;
42
+ const expired = new Promise<never>((_, reject) => {
43
+ timer = setTimeout(() => {
44
+ controller?.abort();
45
+ reject(onTimeout());
46
+ }, ms);
47
+ });
48
+ try {
49
+ return await Promise.race([run(controller?.signal), expired]);
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }