@chipmobilesdk/rn-ai 0.3.0 → 0.5.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +65 -24
  3. package/lib/commonjs/config/behaviorConfig.js +283 -0
  4. package/lib/commonjs/config/behaviorConfig.js.map +1 -0
  5. package/lib/commonjs/contract/gateway.js +60 -0
  6. package/lib/commonjs/contract/gateway.js.map +1 -0
  7. package/lib/commonjs/core/aiClient.js +330 -0
  8. package/lib/commonjs/core/aiClient.js.map +1 -0
  9. package/lib/commonjs/core/quotaRequest.js +105 -0
  10. package/lib/commonjs/core/quotaRequest.js.map +1 -0
  11. package/lib/commonjs/diagnostics/errors.js +108 -0
  12. package/lib/commonjs/diagnostics/errors.js.map +1 -0
  13. package/lib/commonjs/diagnostics/messageKeys.js +68 -0
  14. package/lib/commonjs/diagnostics/messageKeys.js.map +1 -0
  15. package/lib/commonjs/index.js +102 -0
  16. package/lib/commonjs/index.js.map +1 -0
  17. package/lib/commonjs/package.json +1 -0
  18. package/lib/commonjs/react/hooks.js +170 -0
  19. package/lib/commonjs/react/hooks.js.map +1 -0
  20. package/lib/commonjs/transport/sseClient.js +151 -0
  21. package/lib/commonjs/transport/sseClient.js.map +1 -0
  22. package/lib/commonjs/transport/timeouts.js +46 -0
  23. package/lib/commonjs/transport/timeouts.js.map +1 -0
  24. package/lib/module/config/behaviorConfig.js +275 -0
  25. package/lib/module/config/behaviorConfig.js.map +1 -0
  26. package/lib/module/contract/gateway.js +56 -0
  27. package/lib/module/contract/gateway.js.map +1 -0
  28. package/lib/module/core/aiClient.js +327 -0
  29. package/lib/module/core/aiClient.js.map +1 -0
  30. package/lib/module/core/quotaRequest.js +102 -0
  31. package/lib/module/core/quotaRequest.js.map +1 -0
  32. package/lib/module/diagnostics/errors.js +99 -0
  33. package/lib/module/diagnostics/errors.js.map +1 -0
  34. package/lib/module/diagnostics/messageKeys.js +61 -0
  35. package/lib/module/diagnostics/messageKeys.js.map +1 -0
  36. package/lib/module/index.js +19 -0
  37. package/lib/module/index.js.map +1 -0
  38. package/lib/module/package.json +1 -0
  39. package/lib/module/react/hooks.js +164 -0
  40. package/lib/module/react/hooks.js.map +1 -0
  41. package/lib/module/transport/sseClient.js +146 -0
  42. package/lib/module/transport/sseClient.js.map +1 -0
  43. package/lib/module/transport/timeouts.js +41 -0
  44. package/lib/module/transport/timeouts.js.map +1 -0
  45. package/lib/typescript/commonjs/config/behaviorConfig.d.ts +119 -0
  46. package/lib/typescript/commonjs/config/behaviorConfig.d.ts.map +1 -0
  47. package/lib/typescript/commonjs/contract/gateway.d.ts +64 -0
  48. package/lib/typescript/commonjs/contract/gateway.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/core/aiClient.d.ts +104 -0
  50. package/lib/typescript/commonjs/core/aiClient.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/core/quotaRequest.d.ts +45 -0
  52. package/lib/typescript/commonjs/core/quotaRequest.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/diagnostics/errors.d.ts +38 -0
  54. package/lib/typescript/commonjs/diagnostics/errors.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/diagnostics/messageKeys.d.ts +46 -0
  56. package/lib/typescript/commonjs/diagnostics/messageKeys.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/index.d.ts +21 -0
  58. package/lib/typescript/commonjs/index.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/package.json +1 -0
  60. package/lib/typescript/commonjs/react/hooks.d.ts +37 -0
  61. package/lib/typescript/commonjs/react/hooks.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/transport/sseClient.d.ts +57 -0
  63. package/lib/typescript/commonjs/transport/sseClient.d.ts.map +1 -0
  64. package/lib/typescript/commonjs/transport/timeouts.d.ts +28 -0
  65. package/lib/typescript/commonjs/transport/timeouts.d.ts.map +1 -0
  66. package/lib/typescript/module/config/behaviorConfig.d.ts +119 -0
  67. package/lib/typescript/module/config/behaviorConfig.d.ts.map +1 -0
  68. package/lib/typescript/module/contract/gateway.d.ts +64 -0
  69. package/lib/typescript/module/contract/gateway.d.ts.map +1 -0
  70. package/lib/typescript/module/core/aiClient.d.ts +104 -0
  71. package/lib/typescript/module/core/aiClient.d.ts.map +1 -0
  72. package/lib/typescript/module/core/quotaRequest.d.ts +45 -0
  73. package/lib/typescript/module/core/quotaRequest.d.ts.map +1 -0
  74. package/lib/typescript/module/diagnostics/errors.d.ts +38 -0
  75. package/lib/typescript/module/diagnostics/errors.d.ts.map +1 -0
  76. package/lib/typescript/module/diagnostics/messageKeys.d.ts +46 -0
  77. package/lib/typescript/module/diagnostics/messageKeys.d.ts.map +1 -0
  78. package/lib/typescript/module/index.d.ts +21 -0
  79. package/lib/typescript/module/index.d.ts.map +1 -0
  80. package/lib/typescript/module/package.json +1 -0
  81. package/lib/typescript/module/react/hooks.d.ts +37 -0
  82. package/lib/typescript/module/react/hooks.d.ts.map +1 -0
  83. package/lib/typescript/module/transport/sseClient.d.ts +57 -0
  84. package/lib/typescript/module/transport/sseClient.d.ts.map +1 -0
  85. package/lib/typescript/module/transport/timeouts.d.ts +28 -0
  86. package/lib/typescript/module/transport/timeouts.d.ts.map +1 -0
  87. package/package.json +40 -12
  88. package/skills/sdk-rn-ai/SKILL.md +43 -4
  89. package/src/config/behaviorConfig.ts +158 -37
  90. package/src/contract/gateway.ts +91 -0
  91. package/src/core/aiClient.ts +194 -96
  92. package/src/core/quotaRequest.ts +125 -0
  93. package/src/diagnostics/errors.ts +85 -23
  94. package/src/diagnostics/messageKeys.ts +3 -0
  95. package/src/index.ts +28 -1
  96. package/src/react/hooks.ts +86 -14
  97. package/src/transport/sseClient.ts +58 -11
@@ -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';
@@ -11,9 +11,16 @@ import {
11
11
  type AppBehaviorConfig,
12
12
  type BehaviorState,
13
13
  } from '../config/behaviorConfig';
14
+ import {
15
+ SSE_DONE_SENTINEL,
16
+ type GatewayQuota,
17
+ type GatewayStreamEvent,
18
+ type GatewayUsage,
19
+ } from '../contract/gateway';
14
20
  import { AiError, errorFromHttp, type AiErrorCode } from '../diagnostics/errors';
15
- import { requestSse, type SseHandle } from '../transport/sseClient';
16
- import { DEFAULT_AI_TIMEOUTS, withTimeout, type AiTimeouts } from '../transport/timeouts';
21
+ import { requestSse, type SseResponseInfo, type StreamTransport } from '../transport/sseClient';
22
+ import { createQuotaRequest, type RecoveryState } from './quotaRequest';
23
+ import { DEFAULT_AI_TIMEOUTS, type AiTimeouts } from '../transport/timeouts';
17
24
 
18
25
  /** Every failure a caller receives also goes to the app's SDK sinks (CORE-08). */
19
26
  const channel = createDiagnosticChannel<AiErrorCode>('rn-ai');
@@ -25,9 +32,21 @@ function report(operation: 'chat' | 'quota', error: AiError): void {
25
32
  });
26
33
  }
27
34
 
35
+ export interface AccessTokenRequestOptions {
36
+ /**
37
+ * Skip the cached token and ask the provider for a new one. Set after a 401:
38
+ * the cached token is valid by the clock and refused by the server, so asking
39
+ * again without this re-sends the token that was just rejected (AI-01).
40
+ */
41
+ forceRefresh?: boolean;
42
+ }
43
+
28
44
  /** Minimal slice of the rn-auth `Auth` controller the client needs. */
29
45
  export interface TokenProvider {
30
- requestAccessToken(resourceKey: string): Promise<{ status: string; token?: string }>;
46
+ requestAccessToken(
47
+ resourceKey: string,
48
+ options?: AccessTokenRequestOptions,
49
+ ): Promise<{ status: string; token?: string; errorCode?: string }>;
31
50
  }
32
51
 
33
52
  export type ContentPart =
@@ -45,17 +64,11 @@ export interface ChatRequest {
45
64
  options?: { temperature?: number; maxOutputTokens?: number };
46
65
  }
47
66
 
48
- export interface Usage {
49
- promptTokens: number;
50
- completionTokens: number;
51
- totalTokens: number;
52
- }
67
+ /** What one answer cost, as the gateway reports it. */
68
+ export type Usage = GatewayUsage;
53
69
 
54
- export interface QuotaSnapshot {
55
- minuteRemaining: number;
56
- dayRemaining: number;
57
- dayTokensRemaining: number;
58
- }
70
+ /** What is left of the app's allowance. */
71
+ export type QuotaSnapshot = GatewayQuota;
59
72
 
60
73
  export interface ChatStreamHandlers {
61
74
  onDelta(text: string): void;
@@ -80,12 +93,18 @@ export interface AiClientConfig {
80
93
  resourceKey?: string;
81
94
  storage?: AiConfigCache;
82
95
  fetchFn?: typeof fetch;
83
- /** Test seam for the SSE transport. */
84
- transport?: typeof requestSse;
96
+ /** The streaming transport. Injected in tests, or to replace the XHR reader. */
97
+ transport?: StreamTransport;
85
98
  /** Overrides for the network time limits; see `AiTimeouts` for the defaults. */
86
99
  timeouts?: Partial<AiTimeouts>;
87
100
  /** Test seam: how the backoff before a silent retry is waited out. */
88
101
  scheduler?: Scheduler;
102
+ /**
103
+ * Hosts the remote config's `api.baseUrl` may name. A config document decides
104
+ * where this client sends its bearer token, so an app with a fixed deployment
105
+ * can pin it (AI-07).
106
+ */
107
+ allowedHosts?: readonly string[];
89
108
  }
90
109
 
91
110
  /** Ceiling on one remote-configured backoff step, so a bad document cannot stall a chat. */
@@ -100,27 +119,41 @@ function backoffBefore(steps: readonly number[], index: number): number {
100
119
  export interface AiClient {
101
120
  chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle;
102
121
  getQuota(): Promise<QuotaSnapshot>;
122
+ /**
123
+ * The last quota this client saw, from a chat's `done` event or `getQuota`.
124
+ * Optional so an app's own `AiClient` keeps compiling.
125
+ */
126
+ lastQuota?(): QuotaSnapshot | undefined;
103
127
  /** The current behavior state; the same object until it changes. */
104
128
  getBehaviorState(): AiBehaviorState;
105
129
  /**
106
- * Called whenever the behavior state changes (AI-09); returns the unsubscribe.
107
- * Optional so an app's own `AiClient` implementation keeps compiling.
130
+ * Called whenever the behavior state or the last quota changes (AI-09, AI-13);
131
+ * returns the unsubscribe. Optional so an app's own `AiClient` keeps compiling.
108
132
  */
109
133
  subscribe?(listener: () => void): () => void;
110
134
  reloadConfig(): Promise<void>;
111
135
  }
112
136
 
113
- interface StreamEvent {
114
- type?: string;
115
- text?: string;
116
- usage?: Usage;
117
- quota?: QuotaSnapshot;
118
- code?: string;
137
+ /** A quota from the wire, or nothing: a malformed one is not displayed (AI-13). */
138
+ function asQuota(value: unknown): QuotaSnapshot | undefined {
139
+ if (typeof value !== 'object' || value === null) {
140
+ return undefined;
141
+ }
142
+ const candidate = value as Partial<QuotaSnapshot>;
143
+ const fields = [candidate.minuteRemaining, candidate.dayRemaining, candidate.dayTokensRemaining];
144
+ if (!fields.every(field => typeof field === 'number' && Number.isFinite(field))) {
145
+ return undefined;
146
+ }
147
+ return {
148
+ minuteRemaining: candidate.minuteRemaining!,
149
+ dayRemaining: candidate.dayRemaining!,
150
+ dayTokensRemaining: candidate.dayTokensRemaining!,
151
+ };
119
152
  }
120
153
 
121
154
  export function createAiClient(clientConfig: AiClientConfig): AiClient {
122
155
  const resourceKey = clientConfig.resourceKey ?? 'ai-gateway';
123
- const transport = clientConfig.transport ?? requestSse;
156
+ const transport: StreamTransport = clientConfig.transport ?? requestSse;
124
157
  const fetchFn = clientConfig.fetchFn ?? fetch;
125
158
  const timeouts: AiTimeouts = { ...DEFAULT_AI_TIMEOUTS, ...clientConfig.timeouts };
126
159
  const scheduler = clientConfig.scheduler ?? realScheduler;
@@ -130,10 +163,12 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
130
163
  cache: clientConfig.storage,
131
164
  fetchFn: clientConfig.fetchFn,
132
165
  timeoutMs: timeouts.configMs,
166
+ ...(clientConfig.allowedHosts ? { allowedHosts: clientConfig.allowedHosts } : {}),
133
167
  });
134
168
  let lastConfig: AppBehaviorConfig | undefined;
135
169
  const behaviorListeners = createListenerSet<[]>();
136
170
  let snapshot: AiBehaviorState | undefined;
171
+ let quota: QuotaSnapshot | undefined;
137
172
 
138
173
  /** Rebuilt only when a field changes, so useSyncExternalStore sees a stable object. */
139
174
  function behaviorState(): AiBehaviorState {
@@ -151,7 +186,19 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
151
186
  if (next.features.aiEnabled !== wasEnabled) behaviorListeners.notify();
152
187
  }
153
188
 
154
- async function acquire(): Promise<{ config: AppBehaviorConfig; token: string }> {
189
+ /** One place records the quota, whichever call learned it (AI-13). */
190
+ function setQuota(next: QuotaSnapshot | undefined): void {
191
+ if (!next || (quota && shallowSameQuota(quota, next))) {
192
+ return;
193
+ }
194
+ quota = next;
195
+ behaviorListeners.notify();
196
+ }
197
+
198
+ async function acquire(options: { forceRefresh?: boolean } = {}): Promise<{
199
+ config: AppBehaviorConfig;
200
+ token: string;
201
+ }> {
155
202
  const config = await configManager.getConfig();
156
203
  setLastConfig(config);
157
204
  if (!config.features.aiEnabled) {
@@ -159,20 +206,24 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
159
206
  recoverable: false,
160
207
  });
161
208
  }
162
- const tokenResult = await clientConfig.auth.requestAccessToken(resourceKey);
209
+ const tokenResult = await clientConfig.auth.requestAccessToken(resourceKey, {
210
+ forceRefresh: options.forceRefresh === true,
211
+ });
163
212
  if (tokenResult.status !== 'success' || !tokenResult.token) {
164
- throw new AiError('auth-invalid', 'could not acquire an access token');
213
+ // What the auth layer called it, kept as the cause: a screen has to tell
214
+ // "sign in again" from "you are offline" (AI-11).
215
+ throw tokenError(tokenResult.status, tokenResult.errorCode);
165
216
  }
166
217
  return { config, token: tokenResult.token };
167
218
  }
168
219
 
169
- const chatUrl = (config: AppBehaviorConfig) =>
170
- `${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/chat`;
220
+ const endpoint = (config: AppBehaviorConfig, path: 'chat' | 'quota') =>
221
+ `${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/${path}`;
171
222
 
172
223
  function chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle {
173
224
  let cancelled = false;
174
225
  let settled = false;
175
- let inner: SseHandle | undefined;
226
+ let inner: { cancel(): void } | undefined;
176
227
 
177
228
  // Every callback to the app goes through here. Nothing is delivered after
178
229
  // cancel() — an acquire() or config reload that failed after the user
@@ -185,6 +236,7 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
185
236
  done(result: { usage: Usage; quota?: QuotaSnapshot }) {
186
237
  if (cancelled || settled) return;
187
238
  settled = true;
239
+ setQuota(result.quota);
188
240
  handlers.onDone(result);
189
241
  },
190
242
  error(error: AiError) {
@@ -215,15 +267,15 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
215
267
  }
216
268
  };
217
269
 
218
- const attempt = async (state: { configReloads: number; authRetries: number }) => {
219
- const { config, token } = await acquire();
270
+ const attempt = async (state: RecoveryState) => {
271
+ const { config, token } = await acquire({ forceRefresh: state.authRetries > 0 });
220
272
  if (cancelled) return;
221
273
 
222
274
  let done: { usage: Usage; quota?: QuotaSnapshot } | undefined;
223
275
  let streamErrorCode: string | undefined;
224
276
  inner = transport(
225
277
  {
226
- url: chatUrl(config),
278
+ url: endpoint(config, 'chat'),
227
279
  method: 'POST',
228
280
  headers: {
229
281
  Authorization: `Bearer ${token}`,
@@ -235,65 +287,48 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
235
287
  },
236
288
  {
237
289
  onData: guarded(payload => {
238
- if (payload === '[DONE]') return;
239
- let event: StreamEvent;
290
+ if (payload === SSE_DONE_SENTINEL) return;
291
+ let event: Partial<GatewayStreamEvent>;
240
292
  try {
241
- event = JSON.parse(payload) as StreamEvent;
293
+ event = JSON.parse(payload) as Partial<GatewayStreamEvent>;
242
294
  } catch {
243
295
  return;
244
296
  }
245
297
  if (event.type === 'delta' && typeof event.text === 'string') {
246
298
  emit.delta(event.text);
247
299
  } else if (event.type === 'done' && event.usage) {
248
- done = { usage: event.usage, quota: event.quota };
300
+ done = { usage: event.usage, quota: asQuota(event.quota) };
249
301
  } else if (event.type === 'error') {
250
302
  streamErrorCode = event.code ?? 'UPSTREAM_ERROR';
251
303
  }
252
304
  }),
253
305
  onEnd: guarded(() => {
254
306
  if (streamErrorCode) {
255
- emit.error(new AiError('upstream-error', 'the AI service failed mid-response'));
307
+ emit.error(
308
+ new AiError('upstream-error', 'the AI service failed mid-response', {
309
+ cause: streamErrorCode,
310
+ }),
311
+ );
256
312
  } else if (done) {
257
313
  emit.done(done);
258
314
  } else {
259
- emit.error(new AiError('internal', 'stream ended without completion'));
315
+ // Headers said 200 and the body stopped short: the connection
316
+ // dropped mid-answer, which is a network failure, not a fault
317
+ // of the service.
318
+ emit.error(new AiError('network', 'the stream ended before it completed'));
260
319
  }
261
320
  }),
262
- onError: guarded((status, bodyText) => {
321
+ onError: guarded((status: number, bodyText: string, response?: SseResponseInfo) => {
263
322
  if (cancelled) return;
264
- if (status === 0 && bodyText === 'timeout') {
265
- emit.error(
266
- new AiError('upstream-error', 'the AI service stopped responding', {
267
- recoverable: true,
268
- }),
269
- );
270
- return;
271
- }
272
- // Designated config-stale signals → silent reload + retry once (FR-018).
273
- if (
274
- config.recovery.reloadOnHttpStatus.includes(status) &&
275
- state.configReloads < config.recovery.maxSilentRetries
276
- ) {
277
- // Waits `recovery.retryBackoffMs` first (AI-05): a gateway mid-rollout
278
- // that answered 410 a moment ago may well answer it again at once.
279
- const retryAfter = backoffBefore(config.recovery.retryBackoffMs, state.configReloads);
280
- configManager.getConfig({ forceReload: true }).then(
281
- () => {
282
- scheduler.setTimeout(() => {
283
- if (cancelled) return;
284
- attempt({ ...state, configReloads: state.configReloads + 1 }).catch(fail);
285
- }, retryAfter);
286
- },
287
- e => fail(e instanceof AiError ? e : new AiError('config-unavailable', String(e))),
288
- );
289
- return;
290
- }
291
- // Expired server-side session → one fresh-token retry.
292
- if (status === 401 && state.authRetries < 1) {
293
- attempt({ ...state, authRetries: state.authRetries + 1 }).catch(fail);
294
- return;
295
- }
296
- emit.error(errorFromHttp(status, bodyText));
323
+ recover(
324
+ status,
325
+ bodyText,
326
+ state,
327
+ { ...(response ? { getHeader: name => response.getHeader(name) } : {}) },
328
+ next => attempt(next).catch(fail),
329
+ fail,
330
+ emit.error,
331
+ );
297
332
  }),
298
333
  },
299
334
  );
@@ -308,36 +343,66 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
308
343
  };
309
344
  }
310
345
 
311
- async function getQuota(): Promise<QuotaSnapshot> {
312
- try {
313
- return await fetchQuota();
314
- } catch (error) {
315
- if (error instanceof AiError) report('quota', error);
316
- throw error;
346
+ /**
347
+ * The two recoveries the contract promises, in one place so chat and quota
348
+ * cannot drift (AI-13): a designated status reloads the configuration and
349
+ * retries after the configured backoff, and a 401 retries once with a token
350
+ * the provider had to fetch again.
351
+ */
352
+ function recover(
353
+ status: number,
354
+ bodyText: string,
355
+ state: RecoveryState,
356
+ context: { getHeader?: (name: string) => string | null },
357
+ retry: (next: RecoveryState) => void,
358
+ onFailure: (error: unknown) => void,
359
+ onGiveUp: (error: AiError) => void,
360
+ ): void {
361
+ const config = lastConfig;
362
+ if (
363
+ config &&
364
+ config.recovery.reloadOnHttpStatus.includes(status) &&
365
+ state.configReloads < config.recovery.maxSilentRetries
366
+ ) {
367
+ // Waits `recovery.retryBackoffMs` first (AI-05): a gateway mid-rollout
368
+ // that answered 410 a moment ago may well answer it again at once.
369
+ const retryAfter = backoffBefore(config.recovery.retryBackoffMs, state.configReloads);
370
+ configManager.getConfig({ forceReload: true }).then(
371
+ reloaded => {
372
+ setLastConfig(reloaded);
373
+ scheduler.setTimeout(
374
+ () => retry({ ...state, configReloads: state.configReloads + 1 }),
375
+ retryAfter,
376
+ );
377
+ },
378
+ e => onFailure(e instanceof AiError ? e : new AiError('config-unavailable', String(e))),
379
+ );
380
+ return;
317
381
  }
382
+ // Expired server-side session → one retry with a token the provider
383
+ // actually re-fetched.
384
+ if (status === 401 && state.authRetries < 1) {
385
+ retry({ ...state, authRetries: state.authRetries + 1 });
386
+ return;
387
+ }
388
+ onGiveUp(errorFromHttp(status, bodyText, context));
318
389
  }
319
390
 
320
- async function fetchQuota(): Promise<QuotaSnapshot> {
321
- const { config, token } = await acquire();
322
- return withTimeout(
323
- timeouts.quotaMs,
324
- async signal => {
325
- const response = await fetchFn(
326
- `${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/quota`,
327
- { headers: { Authorization: `Bearer ${token}` }, signal },
328
- );
329
- if (!response.ok) {
330
- throw errorFromHttp(response.status, await response.text());
331
- }
332
- return (await response.json()) as QuotaSnapshot;
333
- },
334
- () => new AiError('upstream-error', 'quota request timed out', { recoverable: true }),
335
- );
336
- }
391
+ const { getQuota } = createQuotaRequest({
392
+ fetchFn,
393
+ timeouts,
394
+ acquire,
395
+ endpoint,
396
+ asQuota,
397
+ recover,
398
+ report: error => report('quota', error),
399
+ setQuota,
400
+ });
337
401
 
338
402
  return {
339
403
  chat,
340
404
  getQuota,
405
+ lastQuota: () => quota,
341
406
  getBehaviorState: () => behaviorState(),
342
407
  subscribe: listener => {
343
408
  const fromManager = configManager.subscribe(listener);
@@ -352,3 +417,36 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
352
417
  },
353
418
  };
354
419
  }
420
+
421
+ function shallowSameQuota(a: QuotaSnapshot, b: QuotaSnapshot): boolean {
422
+ return (
423
+ a.minuteRemaining === b.minuteRemaining &&
424
+ a.dayRemaining === b.dayRemaining &&
425
+ a.dayTokensRemaining === b.dayTokensRemaining
426
+ );
427
+ }
428
+
429
+ /**
430
+ * What the auth layer's refusal means for a chat. The code rn-auth gave is kept
431
+ * as the cause, and it decides the copy: "sign in again" and "you are offline"
432
+ * are different answers, and both used to arrive as `auth-invalid` (AI-11).
433
+ */
434
+ function tokenError(status: string, errorCode?: string): AiError {
435
+ const cause = errorCode ? { cause: errorCode } : {};
436
+ switch (errorCode) {
437
+ case 'session-expired':
438
+ return new AiError('session-expired', 'the session ended; sign in again', cause);
439
+ case 'network-unavailable':
440
+ return new AiError('network', 'no connection to the identity provider', cause);
441
+ case 'consent-denied':
442
+ return new AiError('not-entitled', 'access to the AI resource was denied', cause);
443
+ default:
444
+ return new AiError(
445
+ 'auth-invalid',
446
+ status === 'denied'
447
+ ? 'access to the AI resource was refused'
448
+ : 'could not acquire an access token',
449
+ cause,
450
+ );
451
+ }
452
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The quota request, and the recovery it shares with a chat (AI-13).
3
+ *
4
+ * `getQuota` used to be the one call with no recovery at all: a gateway that
5
+ * had moved answered 404 for quota while chats silently re-routed, so the
6
+ * figure on screen failed while the feature worked. It now goes through the
7
+ * same `recover` the chat stream does — a designated status reloads the
8
+ * configuration and retries, a 401 retries once with a token the provider had
9
+ * to fetch again.
10
+ */
11
+ import type { AppBehaviorConfig } from '../config/behaviorConfig';
12
+ import { AiError } from '../diagnostics/errors';
13
+ import { withTimeout, type AiTimeouts } from '../transport/timeouts';
14
+
15
+ /** Where the quota request is in its recovery, shared with the chat path. */
16
+ export interface RecoveryState {
17
+ configReloads: number;
18
+ authRetries: number;
19
+ }
20
+
21
+ export interface QuotaRequestDeps {
22
+ fetchFn: typeof fetch;
23
+ timeouts: AiTimeouts;
24
+ acquire: (options?: { forceRefresh?: boolean }) => Promise<{
25
+ config: AppBehaviorConfig;
26
+ token: string;
27
+ }>;
28
+ endpoint: (config: AppBehaviorConfig, path: 'chat' | 'quota') => string;
29
+ asQuota: (value: unknown) => QuotaSnapshot | undefined;
30
+ recover: (
31
+ status: number,
32
+ bodyText: string,
33
+ state: RecoveryState,
34
+ context: { getHeader?: (name: string) => string | null },
35
+ retry: (next: RecoveryState) => void,
36
+ onFailure: (error: unknown) => void,
37
+ onGiveUp: (error: AiError) => void,
38
+ ) => void;
39
+ report: (error: AiError) => void;
40
+ setQuota: (next: QuotaSnapshot | undefined) => void;
41
+ }
42
+
43
+ /** What is left of the app's allowance. */
44
+ export type QuotaSnapshot = {
45
+ minuteRemaining: number;
46
+ dayRemaining: number;
47
+ dayTokensRemaining: number;
48
+ };
49
+
50
+ export function createQuotaRequest(deps: QuotaRequestDeps) {
51
+ const { fetchFn, timeouts, endpoint, asQuota, recover, setQuota } = deps;
52
+ const acquire = deps.acquire;
53
+
54
+ async function getQuota(): Promise<QuotaSnapshot> {
55
+ try {
56
+ const snapshotValue = await fetchQuotaWithRecovery({ configReloads: 0, authRetries: 0 });
57
+ setQuota(snapshotValue);
58
+ return snapshotValue;
59
+ } catch (error) {
60
+ if (error instanceof AiError) deps.report(error);
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ /** Same recovery as a chat: a retired endpoint reloads the config and retries. */
66
+ function fetchQuotaWithRecovery(state: RecoveryState): Promise<QuotaSnapshot> {
67
+ return new Promise<QuotaSnapshot>((resolve, reject) => {
68
+ fetchQuota(state).then(resolve, error => {
69
+ if (!(error instanceof HttpFailure)) {
70
+ reject(error);
71
+ return;
72
+ }
73
+ recover(
74
+ error.status,
75
+ error.bodyText,
76
+ state,
77
+ { getHeader: name => error.getHeader(name) },
78
+ next => fetchQuotaWithRecovery(next).then(resolve, reject),
79
+ reject,
80
+ reject,
81
+ );
82
+ });
83
+ });
84
+ }
85
+
86
+ /** Carries the response so `recover` can decide; never reaches the caller. */
87
+ class HttpFailure extends Error {
88
+ constructor(
89
+ readonly status: number,
90
+ readonly bodyText: string,
91
+ private readonly headers?: Headers,
92
+ ) {
93
+ super(`gateway responded ${status}`);
94
+ this.name = 'HttpFailure';
95
+ }
96
+ getHeader(name: string): string | null {
97
+ return this.headers?.get(name) ?? null;
98
+ }
99
+ }
100
+
101
+ async function fetchQuota(state: RecoveryState): Promise<QuotaSnapshot> {
102
+ const { config, token } = await acquire({ forceRefresh: state.authRetries > 0 });
103
+ return withTimeout(
104
+ timeouts.quotaMs,
105
+ async signal => {
106
+ const response = await fetchFn(endpoint(config, 'quota'), {
107
+ headers: { Authorization: `Bearer ${token}` },
108
+ signal,
109
+ });
110
+ if (!response.ok) {
111
+ throw new HttpFailure(response.status, await response.text(), response.headers);
112
+ }
113
+ const parsed = asQuota(await response.json());
114
+ if (!parsed) {
115
+ // A body that is not the contract's: better no figure than a wrong one.
116
+ throw new AiError('internal', 'quota response did not match the contract');
117
+ }
118
+ return parsed;
119
+ },
120
+ () => new AiError('network', 'quota request timed out'),
121
+ );
122
+ }
123
+
124
+ return { getQuota };
125
+ }