@chipmobilesdk/rn-ai 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,281 +1,278 @@
1
- import {
2
- BehaviorConfigManager,
3
- type AiConfigCache,
4
- type AppBehaviorConfig,
5
- type BehaviorState,
6
- } from '../config/behaviorConfig';
7
- import { AiError, errorFromHttp } from '../diagnostics/errors';
8
- import { requestSse, type SseHandle } from '../transport/sseClient';
9
- import { DEFAULT_AI_TIMEOUTS, withTimeout, type AiTimeouts } from '../transport/timeouts';
10
-
11
- /** Minimal slice of the rn-auth `Auth` controller the client needs. */
12
- export interface TokenProvider {
13
- requestAccessToken(
14
- resourceKey: string,
15
- ): Promise<{ status: string; token?: string }>;
16
- }
17
-
18
- export type ContentPart =
19
- | { type: 'text'; text: string }
20
- | { type: 'image'; mimeType: string; dataBase64: string }
21
- | { type: 'audio'; format: 'wav' | 'mp3'; dataBase64: string };
22
-
23
- export interface ChatMessage {
24
- role: 'system' | 'user' | 'assistant';
25
- content: ContentPart[];
26
- }
27
-
28
- export interface ChatRequest {
29
- messages: ChatMessage[];
30
- options?: { temperature?: number; maxOutputTokens?: number };
31
- }
32
-
33
- export interface Usage {
34
- promptTokens: number;
35
- completionTokens: number;
36
- totalTokens: number;
37
- }
38
-
39
- export interface QuotaSnapshot {
40
- minuteRemaining: number;
41
- dayRemaining: number;
42
- dayTokensRemaining: number;
43
- }
44
-
45
- export interface ChatStreamHandlers {
46
- onDelta(text: string): void;
47
- onDone(result: { usage: Usage; quota?: QuotaSnapshot }): void;
48
- onError(error: AiError): void;
49
- }
50
-
51
- export interface ChatHandle {
52
- cancel(): void;
53
- }
54
-
55
- export interface AiBehaviorState {
56
- config: BehaviorState;
57
- aiEnabled: boolean;
58
- }
59
-
60
- export interface AiClientConfig {
61
- appId: string;
62
- configUrl: string;
63
- auth: TokenProvider;
64
- /** rn-auth resource key configured for the gateway API scope. */
65
- resourceKey?: string;
66
- storage?: AiConfigCache;
67
- fetchFn?: typeof fetch;
68
- /** Test seam for the SSE transport. */
69
- transport?: typeof requestSse;
70
- /** Overrides for the network time limits; see `AiTimeouts` for the defaults. */
71
- timeouts?: Partial<AiTimeouts>;
72
- }
73
-
74
- export interface AiClient {
75
- chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle;
76
- getQuota(): Promise<QuotaSnapshot>;
77
- getBehaviorState(): AiBehaviorState;
78
- reloadConfig(): Promise<void>;
79
- }
80
-
81
- interface StreamEvent {
82
- type?: string;
83
- text?: string;
84
- usage?: Usage;
85
- quota?: QuotaSnapshot;
86
- code?: string;
87
- }
88
-
89
- export function createAiClient(clientConfig: AiClientConfig): AiClient {
90
- const resourceKey = clientConfig.resourceKey ?? 'ai-gateway';
91
- const transport = clientConfig.transport ?? requestSse;
92
- const fetchFn = clientConfig.fetchFn ?? fetch;
93
- const timeouts: AiTimeouts = { ...DEFAULT_AI_TIMEOUTS, ...clientConfig.timeouts };
94
- const configManager = new BehaviorConfigManager({
95
- configUrl: clientConfig.configUrl,
96
- appId: clientConfig.appId,
97
- cache: clientConfig.storage,
98
- fetchFn: clientConfig.fetchFn,
99
- timeoutMs: timeouts.configMs,
100
- });
101
- let lastConfig: AppBehaviorConfig | undefined;
102
-
103
- async function acquire(): Promise<{ config: AppBehaviorConfig; token: string }> {
104
- const config = await configManager.getConfig();
105
- lastConfig = config;
106
- if (!config.features.aiEnabled) {
107
- throw new AiError('config-unavailable', 'AI is disabled for this app', {
108
- recoverable: false,
109
- });
110
- }
111
- const tokenResult = await clientConfig.auth.requestAccessToken(resourceKey);
112
- if (tokenResult.status !== 'success' || !tokenResult.token) {
113
- throw new AiError('auth-invalid', 'could not acquire an access token');
114
- }
115
- return { config, token: tokenResult.token };
116
- }
117
-
118
- const chatUrl = (config: AppBehaviorConfig) =>
119
- `${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/chat`;
120
-
121
- function chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle {
122
- let cancelled = false;
123
- let settled = false;
124
- let inner: SseHandle | undefined;
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
-
166
- const attempt = async (state: { configReloads: number; authRetries: number }) => {
167
- const { config, token } = await acquire();
168
- if (cancelled) return;
169
-
170
- let done: { usage: Usage; quota?: QuotaSnapshot } | undefined;
171
- let streamErrorCode: string | undefined;
172
- inner = transport(
173
- {
174
- url: chatUrl(config),
175
- method: 'POST',
176
- headers: {
177
- Authorization: `Bearer ${token}`,
178
- 'Content-Type': 'application/json',
179
- },
180
- body: JSON.stringify(request),
181
- connectTimeoutMs: timeouts.connectMs,
182
- idleTimeoutMs: timeouts.idleMs,
183
- },
184
- {
185
- onData: guarded(payload => {
186
- if (payload === '[DONE]') return;
187
- let event: StreamEvent;
188
- try {
189
- event = JSON.parse(payload) as StreamEvent;
190
- } catch {
191
- return;
192
- }
193
- if (event.type === 'delta' && typeof event.text === 'string') {
194
- emit.delta(event.text);
195
- } else if (event.type === 'done' && event.usage) {
196
- done = { usage: event.usage, quota: event.quota };
197
- } else if (event.type === 'error') {
198
- streamErrorCode = event.code ?? 'UPSTREAM_ERROR';
199
- }
200
- }),
201
- onEnd: guarded(() => {
202
- if (streamErrorCode) {
203
- emit.error(new AiError('upstream-error', 'the AI service failed mid-response'));
204
- } else if (done) {
205
- emit.done(done);
206
- } else {
207
- emit.error(new AiError('internal', 'stream ended without completion'));
208
- }
209
- }),
210
- onError: guarded((status, bodyText) => {
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
- }
220
- // Designated config-stale signals → silent reload + retry once (FR-018).
221
- if (
222
- config.recovery.reloadOnHttpStatus.includes(status) &&
223
- state.configReloads < config.recovery.maxSilentRetries
224
- ) {
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
- );
230
- return;
231
- }
232
- // Expired server-side session → one fresh-token retry.
233
- if (status === 401 && state.authRetries < 1) {
234
- attempt({ ...state, authRetries: state.authRetries + 1 }).catch(fail);
235
- return;
236
- }
237
- emit.error(errorFromHttp(status, bodyText));
238
- }),
239
- },
240
- );
241
- };
242
-
243
- attempt({ configReloads: 0, authRetries: 0 }).catch(fail);
244
- return {
245
- cancel() {
246
- cancelled = true;
247
- inner?.cancel();
248
- },
249
- };
250
- }
251
-
252
- async function getQuota(): Promise<QuotaSnapshot> {
253
- const { config, token } = await acquire();
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 }),
267
- );
268
- }
269
-
270
- return {
271
- chat,
272
- getQuota,
273
- getBehaviorState: () => ({
274
- config: configManager.state,
275
- aiEnabled: lastConfig?.features.aiEnabled ?? true,
276
- }),
277
- reloadConfig: async () => {
278
- lastConfig = await configManager.getConfig({ forceReload: true });
279
- },
280
- };
281
- }
1
+ import {
2
+ BehaviorConfigManager,
3
+ type AiConfigCache,
4
+ type AppBehaviorConfig,
5
+ type BehaviorState,
6
+ } from '../config/behaviorConfig';
7
+ import { AiError, errorFromHttp } from '../diagnostics/errors';
8
+ import { requestSse, type SseHandle } from '../transport/sseClient';
9
+ import { DEFAULT_AI_TIMEOUTS, withTimeout, type AiTimeouts } from '../transport/timeouts';
10
+
11
+ /** Minimal slice of the rn-auth `Auth` controller the client needs. */
12
+ export interface TokenProvider {
13
+ requestAccessToken(resourceKey: string): Promise<{ status: string; token?: string }>;
14
+ }
15
+
16
+ export type ContentPart =
17
+ | { type: 'text'; text: string }
18
+ | { type: 'image'; mimeType: string; dataBase64: string }
19
+ | { type: 'audio'; format: 'wav' | 'mp3'; dataBase64: string };
20
+
21
+ export interface ChatMessage {
22
+ role: 'system' | 'user' | 'assistant';
23
+ content: ContentPart[];
24
+ }
25
+
26
+ export interface ChatRequest {
27
+ messages: ChatMessage[];
28
+ options?: { temperature?: number; maxOutputTokens?: number };
29
+ }
30
+
31
+ export interface Usage {
32
+ promptTokens: number;
33
+ completionTokens: number;
34
+ totalTokens: number;
35
+ }
36
+
37
+ export interface QuotaSnapshot {
38
+ minuteRemaining: number;
39
+ dayRemaining: number;
40
+ dayTokensRemaining: number;
41
+ }
42
+
43
+ export interface ChatStreamHandlers {
44
+ onDelta(text: string): void;
45
+ onDone(result: { usage: Usage; quota?: QuotaSnapshot }): void;
46
+ onError(error: AiError): void;
47
+ }
48
+
49
+ export interface ChatHandle {
50
+ cancel(): void;
51
+ }
52
+
53
+ export interface AiBehaviorState {
54
+ config: BehaviorState;
55
+ aiEnabled: boolean;
56
+ }
57
+
58
+ export interface AiClientConfig {
59
+ appId: string;
60
+ configUrl: string;
61
+ auth: TokenProvider;
62
+ /** rn-auth resource key configured for the gateway API scope. */
63
+ resourceKey?: string;
64
+ storage?: AiConfigCache;
65
+ fetchFn?: typeof fetch;
66
+ /** Test seam for the SSE transport. */
67
+ transport?: typeof requestSse;
68
+ /** Overrides for the network time limits; see `AiTimeouts` for the defaults. */
69
+ timeouts?: Partial<AiTimeouts>;
70
+ }
71
+
72
+ export interface AiClient {
73
+ chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle;
74
+ getQuota(): Promise<QuotaSnapshot>;
75
+ getBehaviorState(): AiBehaviorState;
76
+ reloadConfig(): Promise<void>;
77
+ }
78
+
79
+ interface StreamEvent {
80
+ type?: string;
81
+ text?: string;
82
+ usage?: Usage;
83
+ quota?: QuotaSnapshot;
84
+ code?: string;
85
+ }
86
+
87
+ export function createAiClient(clientConfig: AiClientConfig): AiClient {
88
+ const resourceKey = clientConfig.resourceKey ?? 'ai-gateway';
89
+ const transport = clientConfig.transport ?? requestSse;
90
+ const fetchFn = clientConfig.fetchFn ?? fetch;
91
+ const timeouts: AiTimeouts = { ...DEFAULT_AI_TIMEOUTS, ...clientConfig.timeouts };
92
+ const configManager = new BehaviorConfigManager({
93
+ configUrl: clientConfig.configUrl,
94
+ appId: clientConfig.appId,
95
+ cache: clientConfig.storage,
96
+ fetchFn: clientConfig.fetchFn,
97
+ timeoutMs: timeouts.configMs,
98
+ });
99
+ let lastConfig: AppBehaviorConfig | undefined;
100
+
101
+ async function acquire(): Promise<{ config: AppBehaviorConfig; token: string }> {
102
+ const config = await configManager.getConfig();
103
+ lastConfig = config;
104
+ if (!config.features.aiEnabled) {
105
+ throw new AiError('config-unavailable', 'AI is disabled for this app', {
106
+ recoverable: false,
107
+ });
108
+ }
109
+ const tokenResult = await clientConfig.auth.requestAccessToken(resourceKey);
110
+ if (tokenResult.status !== 'success' || !tokenResult.token) {
111
+ throw new AiError('auth-invalid', 'could not acquire an access token');
112
+ }
113
+ return { config, token: tokenResult.token };
114
+ }
115
+
116
+ const chatUrl = (config: AppBehaviorConfig) =>
117
+ `${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/chat`;
118
+
119
+ function chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle {
120
+ let cancelled = false;
121
+ let settled = false;
122
+ let inner: SseHandle | undefined;
123
+
124
+ // Every callback to the app goes through here. Nothing is delivered after
125
+ // cancel() — an acquire() or config reload that failed after the user
126
+ // cancelled (or sent the next message) used to flip that screen to
127
+ // `error` — and exactly one terminal callback is delivered.
128
+ const emit = {
129
+ delta(text: string) {
130
+ if (!cancelled && !settled) handlers.onDelta(text);
131
+ },
132
+ done(result: { usage: Usage; quota?: QuotaSnapshot }) {
133
+ if (cancelled || settled) return;
134
+ settled = true;
135
+ handlers.onDone(result);
136
+ },
137
+ error(error: AiError) {
138
+ if (cancelled || settled) return;
139
+ settled = true;
140
+ inner?.cancel();
141
+ handlers.onError(error);
142
+ },
143
+ };
144
+
145
+ // Any exception on the way becomes a reported error. Each path used to end
146
+ // in `.catch(() => undefined)`, so a transport that threw (a bad URL from
147
+ // remote config, say) left the UI in `loading` with nothing to show.
148
+ const fail = (e: unknown) =>
149
+ emit.error(
150
+ e instanceof AiError
151
+ ? e
152
+ : new AiError('internal', e instanceof Error ? e.message : String(e)),
153
+ );
154
+ const guarded =
155
+ <A extends unknown[]>(fn: (...args: A) => void) =>
156
+ (...args: A) => {
157
+ try {
158
+ fn(...args);
159
+ } catch (e) {
160
+ fail(e);
161
+ }
162
+ };
163
+
164
+ const attempt = async (state: { configReloads: number; authRetries: number }) => {
165
+ const { config, token } = await acquire();
166
+ if (cancelled) return;
167
+
168
+ let done: { usage: Usage; quota?: QuotaSnapshot } | undefined;
169
+ let streamErrorCode: string | undefined;
170
+ inner = transport(
171
+ {
172
+ url: chatUrl(config),
173
+ method: 'POST',
174
+ headers: {
175
+ Authorization: `Bearer ${token}`,
176
+ 'Content-Type': 'application/json',
177
+ },
178
+ body: JSON.stringify(request),
179
+ connectTimeoutMs: timeouts.connectMs,
180
+ idleTimeoutMs: timeouts.idleMs,
181
+ },
182
+ {
183
+ onData: guarded(payload => {
184
+ if (payload === '[DONE]') return;
185
+ let event: StreamEvent;
186
+ try {
187
+ event = JSON.parse(payload) as StreamEvent;
188
+ } catch {
189
+ return;
190
+ }
191
+ if (event.type === 'delta' && typeof event.text === 'string') {
192
+ emit.delta(event.text);
193
+ } else if (event.type === 'done' && event.usage) {
194
+ done = { usage: event.usage, quota: event.quota };
195
+ } else if (event.type === 'error') {
196
+ streamErrorCode = event.code ?? 'UPSTREAM_ERROR';
197
+ }
198
+ }),
199
+ onEnd: guarded(() => {
200
+ if (streamErrorCode) {
201
+ emit.error(new AiError('upstream-error', 'the AI service failed mid-response'));
202
+ } else if (done) {
203
+ emit.done(done);
204
+ } else {
205
+ emit.error(new AiError('internal', 'stream ended without completion'));
206
+ }
207
+ }),
208
+ onError: guarded((status, bodyText) => {
209
+ if (cancelled) return;
210
+ if (status === 0 && bodyText === 'timeout') {
211
+ emit.error(
212
+ new AiError('upstream-error', 'the AI service stopped responding', {
213
+ recoverable: true,
214
+ }),
215
+ );
216
+ return;
217
+ }
218
+ // Designated config-stale signals → silent reload + retry once (FR-018).
219
+ if (
220
+ config.recovery.reloadOnHttpStatus.includes(status) &&
221
+ state.configReloads < config.recovery.maxSilentRetries
222
+ ) {
223
+ configManager.getConfig({ forceReload: true }).then(
224
+ () => attempt({ ...state, configReloads: state.configReloads + 1 }).catch(fail),
225
+ e => fail(e instanceof AiError ? e : new AiError('config-unavailable', String(e))),
226
+ );
227
+ return;
228
+ }
229
+ // Expired server-side session → one fresh-token retry.
230
+ if (status === 401 && state.authRetries < 1) {
231
+ attempt({ ...state, authRetries: state.authRetries + 1 }).catch(fail);
232
+ return;
233
+ }
234
+ emit.error(errorFromHttp(status, bodyText));
235
+ }),
236
+ },
237
+ );
238
+ };
239
+
240
+ attempt({ configReloads: 0, authRetries: 0 }).catch(fail);
241
+ return {
242
+ cancel() {
243
+ cancelled = true;
244
+ inner?.cancel();
245
+ },
246
+ };
247
+ }
248
+
249
+ async function getQuota(): Promise<QuotaSnapshot> {
250
+ const { config, token } = await acquire();
251
+ return withTimeout(
252
+ timeouts.quotaMs,
253
+ async signal => {
254
+ const response = await fetchFn(
255
+ `${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/quota`,
256
+ { headers: { Authorization: `Bearer ${token}` }, signal },
257
+ );
258
+ if (!response.ok) {
259
+ throw errorFromHttp(response.status, await response.text());
260
+ }
261
+ return (await response.json()) as QuotaSnapshot;
262
+ },
263
+ () => new AiError('upstream-error', 'quota request timed out', { recoverable: true }),
264
+ );
265
+ }
266
+
267
+ return {
268
+ chat,
269
+ getQuota,
270
+ getBehaviorState: () => ({
271
+ config: configManager.state,
272
+ aiEnabled: lastConfig?.features.aiEnabled ?? true,
273
+ }),
274
+ reloadConfig: async () => {
275
+ lastConfig = await configManager.getConfig({ forceReload: true });
276
+ },
277
+ };
278
+ }