@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.
- package/CHANGELOG.md +113 -0
- package/README.md +72 -26
- package/package.json +5 -2
- package/skills/sdk-rn-ai/SKILL.md +35 -4
- package/src/config/behaviorConfig.ts +193 -39
- package/src/contract/gateway.ts +91 -0
- package/src/core/aiClient.ts +260 -86
- package/src/core/quotaRequest.ts +125 -0
- package/src/diagnostics/errors.ts +108 -31
- package/src/diagnostics/messageKeys.ts +58 -19
- package/src/index.ts +30 -3
- package/src/react/hooks.ts +102 -12
- package/src/transport/sseClient.ts +58 -11
package/src/core/aiClient.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
|
|
9
|
-
|
|
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(
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
/**
|
|
67
|
-
transport?:
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
117
|
-
`${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}`;
|
|
118
222
|
|
|
119
223
|
function chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle {
|
|
120
224
|
let cancelled = false;
|
|
121
225
|
let settled = false;
|
|
122
|
-
let inner:
|
|
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:
|
|
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:
|
|
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 ===
|
|
185
|
-
let event:
|
|
290
|
+
if (payload === SSE_DONE_SENTINEL) return;
|
|
291
|
+
let event: Partial<GatewayStreamEvent>;
|
|
186
292
|
try {
|
|
187
|
-
event = JSON.parse(payload) as
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
+
}
|