@chipmobilesdk/rn-ai 0.3.0 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +60 -22
- package/package.json +1 -1
- package/skills/sdk-rn-ai/SKILL.md +35 -4
- package/src/config/behaviorConfig.ts +158 -37
- package/src/contract/gateway.ts +91 -0
- package/src/core/aiClient.ts +194 -96
- package/src/core/quotaRequest.ts +125 -0
- package/src/diagnostics/errors.ts +85 -23
- package/src/diagnostics/messageKeys.ts +3 -0
- package/src/index.ts +28 -1
- package/src/react/hooks.ts +86 -14
- package/src/transport/sseClient.ts +58 -11
package/src/core/aiClient.ts
CHANGED
|
@@ -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
|
|
16
|
-
import {
|
|
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(
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
/**
|
|
84
|
-
transport?:
|
|
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);
|
|
107
|
-
* Optional so an app's own `AiClient`
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
`${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}
|
|
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:
|
|
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:
|
|
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:
|
|
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 ===
|
|
239
|
-
let event:
|
|
290
|
+
if (payload === SSE_DONE_SENTINEL) return;
|
|
291
|
+
let event: Partial<GatewayStreamEvent>;
|
|
240
292
|
try {
|
|
241
|
-
event = JSON.parse(payload) as
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
}
|