@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 +35 -0
- package/README.md +74 -57
- package/package.json +1 -1
- package/skills/sdk-rn-ai/SKILL.md +57 -57
- package/src/config/behaviorConfig.ts +22 -5
- package/src/core/aiClient.ts +88 -37
- package/src/diagnostics/errors.ts +5 -1
- package/src/index.ts +1 -0
- package/src/react/hooks.ts +24 -3
- package/src/transport/sseClient.ts +37 -0
- package/src/transport/timeouts.ts +53 -0
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.
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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,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.
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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,
|
package/src/core/aiClient.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
emit.error(new AiError('upstream-error', 'the AI service failed mid-response'));
|
|
164
204
|
} else if (done) {
|
|
165
|
-
|
|
205
|
+
emit.done(done);
|
|
166
206
|
} else {
|
|
167
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(
|
|
234
|
+
attempt({ ...state, authRetries: state.authRetries + 1 }).catch(fail);
|
|
190
235
|
return;
|
|
191
236
|
}
|
|
192
|
-
|
|
193
|
-
},
|
|
237
|
+
emit.error(errorFromHttp(status, bodyText));
|
|
238
|
+
}),
|
|
194
239
|
},
|
|
195
240
|
);
|
|
196
241
|
};
|
|
197
242
|
|
|
198
|
-
attempt({ configReloads: 0, authRetries: 0 }).catch(
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
package/src/react/hooks.ts
CHANGED
|
@@ -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(
|
|
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 (!
|
|
116
|
+
if (!current()) return;
|
|
96
117
|
setQuota(snapshot);
|
|
97
118
|
setStatus('ready');
|
|
98
119
|
})
|
|
99
120
|
.catch(() => {
|
|
100
|
-
if (
|
|
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
|
+
}
|