@chipmobilesdk/rn-ai 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,52 @@
1
+ import {
2
+ createDiagnosticChannel,
3
+ createListenerSet,
4
+ realScheduler,
5
+ type Scheduler,
6
+ } from '@chipmobilesdk/rn-core';
7
+
1
8
  import {
2
9
  BehaviorConfigManager,
3
10
  type AiConfigCache,
4
11
  type AppBehaviorConfig,
5
12
  type BehaviorState,
6
13
  } 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';
14
+ import {
15
+ SSE_DONE_SENTINEL,
16
+ type GatewayQuota,
17
+ type GatewayStreamEvent,
18
+ type GatewayUsage,
19
+ } from '../contract/gateway';
20
+ import { AiError, errorFromHttp, type AiErrorCode } from '../diagnostics/errors';
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';
24
+
25
+ /** Every failure a caller receives also goes to the app's SDK sinks (CORE-08). */
26
+ const channel = createDiagnosticChannel<AiErrorCode>('rn-ai');
27
+
28
+ function report(operation: 'chat' | 'quota', error: AiError): void {
29
+ channel.emit(error.code, error.retryable ? 'warn' : 'error', {
30
+ operation,
31
+ retryable: error.retryable,
32
+ });
33
+ }
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
+ }
10
43
 
11
44
  /** Minimal slice of the rn-auth `Auth` controller the client needs. */
12
45
  export interface TokenProvider {
13
- requestAccessToken(resourceKey: string): Promise<{ status: string; token?: string }>;
46
+ requestAccessToken(
47
+ resourceKey: string,
48
+ options?: AccessTokenRequestOptions,
49
+ ): Promise<{ status: string; token?: string; errorCode?: string }>;
14
50
  }
15
51
 
16
52
  export type ContentPart =
@@ -28,17 +64,11 @@ export interface ChatRequest {
28
64
  options?: { temperature?: number; maxOutputTokens?: number };
29
65
  }
30
66
 
31
- export interface Usage {
32
- promptTokens: number;
33
- completionTokens: number;
34
- totalTokens: number;
35
- }
67
+ /** What one answer cost, as the gateway reports it. */
68
+ export type Usage = GatewayUsage;
36
69
 
37
- export interface QuotaSnapshot {
38
- minuteRemaining: number;
39
- dayRemaining: number;
40
- dayTokensRemaining: number;
41
- }
70
+ /** What is left of the app's allowance. */
71
+ export type QuotaSnapshot = GatewayQuota;
42
72
 
43
73
  export interface ChatStreamHandlers {
44
74
  onDelta(text: string): void;
@@ -63,63 +93,137 @@ export interface AiClientConfig {
63
93
  resourceKey?: string;
64
94
  storage?: AiConfigCache;
65
95
  fetchFn?: typeof fetch;
66
- /** Test seam for the SSE transport. */
67
- transport?: typeof requestSse;
96
+ /** The streaming transport. Injected in tests, or to replace the XHR reader. */
97
+ transport?: StreamTransport;
68
98
  /** Overrides for the network time limits; see `AiTimeouts` for the defaults. */
69
99
  timeouts?: Partial<AiTimeouts>;
100
+ /** Test seam: how the backoff before a silent retry is waited out. */
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[];
108
+ }
109
+
110
+ /** Ceiling on one remote-configured backoff step, so a bad document cannot stall a chat. */
111
+ const MAX_RETRY_BACKOFF_MS = 30_000;
112
+
113
+ /** The wait before silent retry `index` (0-based); the last configured step repeats. */
114
+ function backoffBefore(steps: readonly number[], index: number): number {
115
+ const step = steps[Math.min(index, steps.length - 1)] ?? 0;
116
+ return Math.min(Math.max(0, step), MAX_RETRY_BACKOFF_MS);
70
117
  }
71
118
 
72
119
  export interface AiClient {
73
120
  chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle;
74
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;
127
+ /** The current behavior state; the same object until it changes. */
75
128
  getBehaviorState(): AiBehaviorState;
129
+ /**
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.
132
+ */
133
+ subscribe?(listener: () => void): () => void;
76
134
  reloadConfig(): Promise<void>;
77
135
  }
78
136
 
79
- interface StreamEvent {
80
- type?: string;
81
- text?: string;
82
- usage?: Usage;
83
- quota?: QuotaSnapshot;
84
- 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
+ };
85
152
  }
86
153
 
87
154
  export function createAiClient(clientConfig: AiClientConfig): AiClient {
88
155
  const resourceKey = clientConfig.resourceKey ?? 'ai-gateway';
89
- const transport = clientConfig.transport ?? requestSse;
156
+ const transport: StreamTransport = clientConfig.transport ?? requestSse;
90
157
  const fetchFn = clientConfig.fetchFn ?? fetch;
91
158
  const timeouts: AiTimeouts = { ...DEFAULT_AI_TIMEOUTS, ...clientConfig.timeouts };
159
+ const scheduler = clientConfig.scheduler ?? realScheduler;
92
160
  const configManager = new BehaviorConfigManager({
93
161
  configUrl: clientConfig.configUrl,
94
162
  appId: clientConfig.appId,
95
163
  cache: clientConfig.storage,
96
164
  fetchFn: clientConfig.fetchFn,
97
165
  timeoutMs: timeouts.configMs,
166
+ ...(clientConfig.allowedHosts ? { allowedHosts: clientConfig.allowedHosts } : {}),
98
167
  });
99
168
  let lastConfig: AppBehaviorConfig | undefined;
169
+ const behaviorListeners = createListenerSet<[]>();
170
+ let snapshot: AiBehaviorState | undefined;
171
+ let quota: QuotaSnapshot | undefined;
172
+
173
+ /** Rebuilt only when a field changes, so useSyncExternalStore sees a stable object. */
174
+ function behaviorState(): AiBehaviorState {
175
+ const config = configManager.state;
176
+ const aiEnabled = lastConfig?.features.aiEnabled ?? true;
177
+ if (!snapshot || snapshot.config !== config || snapshot.aiEnabled !== aiEnabled) {
178
+ snapshot = { config, aiEnabled };
179
+ }
180
+ return snapshot;
181
+ }
100
182
 
101
- async function acquire(): Promise<{ config: AppBehaviorConfig; token: string }> {
183
+ function setLastConfig(next: AppBehaviorConfig): void {
184
+ const wasEnabled = lastConfig?.features.aiEnabled ?? true;
185
+ lastConfig = next;
186
+ if (next.features.aiEnabled !== wasEnabled) behaviorListeners.notify();
187
+ }
188
+
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
+ }> {
102
202
  const config = await configManager.getConfig();
103
- lastConfig = config;
203
+ setLastConfig(config);
104
204
  if (!config.features.aiEnabled) {
105
205
  throw new AiError('config-unavailable', 'AI is disabled for this app', {
106
206
  recoverable: false,
107
207
  });
108
208
  }
109
- const tokenResult = await clientConfig.auth.requestAccessToken(resourceKey);
209
+ const tokenResult = await clientConfig.auth.requestAccessToken(resourceKey, {
210
+ forceRefresh: options.forceRefresh === true,
211
+ });
110
212
  if (tokenResult.status !== 'success' || !tokenResult.token) {
111
- 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);
112
216
  }
113
217
  return { config, token: tokenResult.token };
114
218
  }
115
219
 
116
- const chatUrl = (config: AppBehaviorConfig) =>
117
- `${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}`;
118
222
 
119
223
  function chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle {
120
224
  let cancelled = false;
121
225
  let settled = false;
122
- let inner: SseHandle | undefined;
226
+ let inner: { cancel(): void } | undefined;
123
227
 
124
228
  // Every callback to the app goes through here. Nothing is delivered after
125
229
  // cancel() — an acquire() or config reload that failed after the user
@@ -132,12 +236,14 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
132
236
  done(result: { usage: Usage; quota?: QuotaSnapshot }) {
133
237
  if (cancelled || settled) return;
134
238
  settled = true;
239
+ setQuota(result.quota);
135
240
  handlers.onDone(result);
136
241
  },
137
242
  error(error: AiError) {
138
243
  if (cancelled || settled) return;
139
244
  settled = true;
140
245
  inner?.cancel();
246
+ report('chat', error);
141
247
  handlers.onError(error);
142
248
  },
143
249
  };
@@ -161,15 +267,15 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
161
267
  }
162
268
  };
163
269
 
164
- const attempt = async (state: { configReloads: number; authRetries: number }) => {
165
- const { config, token } = await acquire();
270
+ const attempt = async (state: RecoveryState) => {
271
+ const { config, token } = await acquire({ forceRefresh: state.authRetries > 0 });
166
272
  if (cancelled) return;
167
273
 
168
274
  let done: { usage: Usage; quota?: QuotaSnapshot } | undefined;
169
275
  let streamErrorCode: string | undefined;
170
276
  inner = transport(
171
277
  {
172
- url: chatUrl(config),
278
+ url: endpoint(config, 'chat'),
173
279
  method: 'POST',
174
280
  headers: {
175
281
  Authorization: `Bearer ${token}`,
@@ -181,57 +287,48 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
181
287
  },
182
288
  {
183
289
  onData: guarded(payload => {
184
- if (payload === '[DONE]') return;
185
- let event: StreamEvent;
290
+ if (payload === SSE_DONE_SENTINEL) return;
291
+ let event: Partial<GatewayStreamEvent>;
186
292
  try {
187
- event = JSON.parse(payload) as StreamEvent;
293
+ event = JSON.parse(payload) as Partial<GatewayStreamEvent>;
188
294
  } catch {
189
295
  return;
190
296
  }
191
297
  if (event.type === 'delta' && typeof event.text === 'string') {
192
298
  emit.delta(event.text);
193
299
  } else if (event.type === 'done' && event.usage) {
194
- done = { usage: event.usage, quota: event.quota };
300
+ done = { usage: event.usage, quota: asQuota(event.quota) };
195
301
  } else if (event.type === 'error') {
196
302
  streamErrorCode = event.code ?? 'UPSTREAM_ERROR';
197
303
  }
198
304
  }),
199
305
  onEnd: guarded(() => {
200
306
  if (streamErrorCode) {
201
- 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
+ );
202
312
  } else if (done) {
203
313
  emit.done(done);
204
314
  } else {
205
- 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'));
206
319
  }
207
320
  }),
208
- onError: guarded((status, bodyText) => {
321
+ onError: guarded((status: number, bodyText: string, response?: SseResponseInfo) => {
209
322
  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));
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
+ );
235
332
  }),
236
333
  },
237
334
  );
@@ -246,33 +343,110 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
246
343
  };
247
344
  }
248
345
 
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
- );
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;
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));
265
389
  }
266
390
 
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
+ });
401
+
267
402
  return {
268
403
  chat,
269
404
  getQuota,
270
- getBehaviorState: () => ({
271
- config: configManager.state,
272
- aiEnabled: lastConfig?.features.aiEnabled ?? true,
273
- }),
405
+ lastQuota: () => quota,
406
+ getBehaviorState: () => behaviorState(),
407
+ subscribe: listener => {
408
+ const fromManager = configManager.subscribe(listener);
409
+ const fromClient = behaviorListeners.add(listener);
410
+ return () => {
411
+ fromManager();
412
+ fromClient();
413
+ };
414
+ },
274
415
  reloadConfig: async () => {
275
- lastConfig = await configManager.getConfig({ forceReload: true });
416
+ setLastConfig(await configManager.getConfig({ forceReload: true }));
276
417
  },
277
418
  };
278
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
+ }