@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createListenerSet, createSingleFlight, type KeyValuePort } from '@chipmobilesdk/rn-core';
|
|
2
|
+
|
|
1
3
|
import { AiError } from '../diagnostics/errors';
|
|
2
4
|
import { DEFAULT_AI_TIMEOUTS, withTimeout } from '../transport/timeouts';
|
|
3
5
|
|
|
@@ -33,18 +35,84 @@ export class MemoryConfigCache implements AiConfigCache {
|
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* An `AiConfigCache` over rn-core's synchronous `KeyValuePort` (AI-10).
|
|
40
|
+
*
|
|
41
|
+
* Without a durable cache every launch waits for the network before the first
|
|
42
|
+
* chat, which is exactly what the cache-first design exists to avoid. Open the
|
|
43
|
+
* port the way the app opens its other durable state, for example
|
|
44
|
+
* `openDurable(() => createMMKV({ id: 'app' }))`.
|
|
45
|
+
*/
|
|
46
|
+
export function configCacheFromKeyValue(port: KeyValuePort): AiConfigCache {
|
|
47
|
+
return {
|
|
48
|
+
async get(key) {
|
|
49
|
+
return port.getString(key) ?? null;
|
|
50
|
+
},
|
|
51
|
+
async set(key, value) {
|
|
52
|
+
port.set(key, value);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
export type BehaviorState = 'unloaded' | 'ready-network' | 'ready-cache' | 'unavailable';
|
|
37
58
|
|
|
59
|
+
/** The newest document layout this release understands. */
|
|
60
|
+
export const SUPPORTED_SCHEMA_VERSION = 1;
|
|
61
|
+
|
|
62
|
+
/** Bounds on the remote-configured recovery, so a bad document cannot stall a chat. */
|
|
63
|
+
const MAX_SILENT_RETRIES = 5;
|
|
64
|
+
const MIN_REFRESH_TTL_SECONDS = 30;
|
|
65
|
+
const DEFAULT_REFRESH_TTL_SECONDS = 21_600;
|
|
66
|
+
|
|
38
67
|
const numbers = (value: unknown): number[] | undefined =>
|
|
39
68
|
Array.isArray(value) ? value.filter((n): n is number => Number.isFinite(n)) : undefined;
|
|
40
69
|
|
|
41
|
-
|
|
42
|
-
|
|
70
|
+
const positiveInt = (value: unknown, fallback: number): number =>
|
|
71
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
72
|
+
|
|
73
|
+
function invalid(reason: string): never {
|
|
74
|
+
throw new AiError('config-unavailable', `behavior config ${reason}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** https, or http on a development machine: a bearer token travels to this host. */
|
|
78
|
+
export function isTrustedUrl(url: string): boolean {
|
|
79
|
+
if (/^https:\/\//i.test(url)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(url);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hostOf(url: string): string {
|
|
86
|
+
return url
|
|
87
|
+
.replace(/^[a-z]+:\/\//i, '')
|
|
88
|
+
.split(/[/:?#]/)[0]!
|
|
89
|
+
.toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ParseBehaviorConfigOptions {
|
|
93
|
+
/** The app this document must be for; a document for another app is refused. */
|
|
94
|
+
expectedAppId?: string;
|
|
95
|
+
/** Hosts `api.baseUrl` may name. Any host is accepted when omitted. */
|
|
96
|
+
allowedHosts?: readonly string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse + validate; unknown fields ignored (forward compatibility).
|
|
101
|
+
*
|
|
102
|
+
* Every field this package acts on is checked here, because the document is
|
|
103
|
+
* remote and the alternative is a TypeError inside an XHR callback, a revalidate
|
|
104
|
+
* on every single request, or a bearer token sent to a host nobody vetted
|
|
105
|
+
* (AI-06, AI-07).
|
|
106
|
+
*/
|
|
107
|
+
export function parseBehaviorConfig(
|
|
108
|
+
json: string,
|
|
109
|
+
options: ParseBehaviorConfigOptions = {},
|
|
110
|
+
): AppBehaviorConfig {
|
|
43
111
|
let raw: Record<string, unknown>;
|
|
44
112
|
try {
|
|
45
113
|
raw = JSON.parse(json) as Record<string, unknown>;
|
|
46
114
|
} catch {
|
|
47
|
-
|
|
115
|
+
invalid('is not valid JSON');
|
|
48
116
|
}
|
|
49
117
|
const api = raw.api as Partial<AppBehaviorConfig['api']> | undefined;
|
|
50
118
|
if (
|
|
@@ -52,17 +120,42 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
|
|
|
52
120
|
typeof api?.baseUrl !== 'string' ||
|
|
53
121
|
!Array.isArray(api.scopes)
|
|
54
122
|
) {
|
|
55
|
-
|
|
123
|
+
invalid('is missing required fields');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const schemaVersion = positiveInt(raw.schemaVersion, 1);
|
|
127
|
+
if (schemaVersion > SUPPORTED_SCHEMA_VERSION) {
|
|
128
|
+
// A newer layout may mean something different by the same field names.
|
|
129
|
+
invalid(`declares schema version ${schemaVersion}, newer than ${SUPPORTED_SCHEMA_VERSION}`);
|
|
56
130
|
}
|
|
131
|
+
if (options.expectedAppId !== undefined && raw.appId !== options.expectedAppId) {
|
|
132
|
+
// The cache key and the request path carry the app id; a document for
|
|
133
|
+
// another app would send this app's traffic somewhere else entirely.
|
|
134
|
+
invalid(`is for another app (${raw.appId})`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const baseUrl = api.baseUrl.replace(/\/$/, '');
|
|
138
|
+
if (!isTrustedUrl(baseUrl)) {
|
|
139
|
+
invalid('names a baseUrl that is not https');
|
|
140
|
+
}
|
|
141
|
+
if (options.allowedHosts && !options.allowedHosts.includes(hostOf(baseUrl))) {
|
|
142
|
+
invalid(`names a baseUrl outside the allowed hosts (${hostOf(baseUrl)})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
57
145
|
const recovery = (raw.recovery ?? {}) as Partial<AppBehaviorConfig['recovery']>;
|
|
58
146
|
const features = (raw.features ?? {}) as Partial<AppBehaviorConfig['features']>;
|
|
59
147
|
return {
|
|
60
|
-
schemaVersion
|
|
148
|
+
schemaVersion,
|
|
61
149
|
appId: raw.appId,
|
|
62
|
-
configVersion:
|
|
63
|
-
|
|
150
|
+
configVersion: positiveInt(raw.configVersion, 0),
|
|
151
|
+
// A TTL that was NaN made `isFresh` false forever, so every request
|
|
152
|
+
// revalidated over the network (AI-06).
|
|
153
|
+
refreshTtlSeconds: Math.max(
|
|
154
|
+
MIN_REFRESH_TTL_SECONDS,
|
|
155
|
+
positiveInt(raw.refreshTtlSeconds, DEFAULT_REFRESH_TTL_SECONDS),
|
|
156
|
+
),
|
|
64
157
|
api: {
|
|
65
|
-
baseUrl
|
|
158
|
+
baseUrl,
|
|
66
159
|
scopes: api.scopes.filter((s): s is string => typeof s === 'string'),
|
|
67
160
|
supportsStreaming: api.supportsStreaming !== false,
|
|
68
161
|
},
|
|
@@ -71,9 +164,12 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
|
|
|
71
164
|
// throw a TypeError inside the XHR error callback, where nothing caught it.
|
|
72
165
|
recovery: {
|
|
73
166
|
reloadOnHttpStatus: numbers(recovery.reloadOnHttpStatus) ?? [404, 410],
|
|
74
|
-
maxSilentRetries:
|
|
75
|
-
|
|
76
|
-
|
|
167
|
+
maxSilentRetries: Math.min(
|
|
168
|
+
MAX_SILENT_RETRIES,
|
|
169
|
+
Number.isFinite(recovery.maxSilentRetries)
|
|
170
|
+
? Math.max(0, Number(recovery.maxSilentRetries))
|
|
171
|
+
: 1,
|
|
172
|
+
),
|
|
77
173
|
retryBackoffMs: numbers(recovery.retryBackoffMs) ?? [500, 2000],
|
|
78
174
|
},
|
|
79
175
|
features: { aiEnabled: features.aiEnabled !== false },
|
|
@@ -94,6 +190,8 @@ export interface BehaviorConfigDeps {
|
|
|
94
190
|
now?: () => number;
|
|
95
191
|
/** Limit on one config fetch, body included. Defaults to 10 s. */
|
|
96
192
|
timeoutMs?: number;
|
|
193
|
+
/** Hosts the document's `api.baseUrl` may name. */
|
|
194
|
+
allowedHosts?: readonly string[];
|
|
97
195
|
}
|
|
98
196
|
|
|
99
197
|
/**
|
|
@@ -106,64 +204,125 @@ export class BehaviorConfigManager {
|
|
|
106
204
|
private fetchFn: typeof fetch;
|
|
107
205
|
private now: () => number;
|
|
108
206
|
private current?: { config: AppBehaviorConfig; envelope: CacheEnvelope };
|
|
109
|
-
|
|
207
|
+
/**
|
|
208
|
+
* One cache read and one network fetch at a time (AI-08): a cold start makes a
|
|
209
|
+
* chat and a quota call together, and each used to fetch the config itself.
|
|
210
|
+
*/
|
|
211
|
+
private readonly cacheRead = createSingleFlight<void>();
|
|
212
|
+
private readonly networkFetch = createSingleFlight<AppBehaviorConfig>();
|
|
213
|
+
private currentState: BehaviorState = 'unloaded';
|
|
214
|
+
private readonly stateListeners = createListenerSet<[]>();
|
|
215
|
+
|
|
216
|
+
/** Where the config came from, or why there is none. */
|
|
217
|
+
get state(): BehaviorState {
|
|
218
|
+
return this.currentState;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Called whenever `state` changes (AI-09). */
|
|
222
|
+
subscribe(listener: () => void): () => void {
|
|
223
|
+
return this.stateListeners.add(listener);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private setState(next: BehaviorState): void {
|
|
227
|
+
if (next === this.currentState) return;
|
|
228
|
+
this.currentState = next;
|
|
229
|
+
this.stateListeners.notify();
|
|
230
|
+
}
|
|
110
231
|
|
|
111
232
|
constructor(private deps: BehaviorConfigDeps) {
|
|
233
|
+
if (!isTrustedUrl(deps.configUrl)) {
|
|
234
|
+
// The document decides where the tokens go, so where it comes from
|
|
235
|
+
// matters as much (AI-07).
|
|
236
|
+
throw new AiError('config-unavailable', 'configUrl must be https');
|
|
237
|
+
}
|
|
112
238
|
this.cacheKey = `rn-ai:config:${deps.appId}`;
|
|
113
239
|
this.cache = deps.cache ?? new MemoryConfigCache();
|
|
114
240
|
this.fetchFn = deps.fetchFn ?? fetch;
|
|
115
241
|
this.now = deps.now ?? Date.now;
|
|
116
242
|
}
|
|
117
243
|
|
|
244
|
+
private parse(json: string): AppBehaviorConfig {
|
|
245
|
+
return parseBehaviorConfig(json, {
|
|
246
|
+
expectedAppId: this.deps.appId,
|
|
247
|
+
...(this.deps.allowedHosts ? { allowedHosts: this.deps.allowedHosts } : {}),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
118
251
|
private isFresh(envelope: CacheEnvelope, config: AppBehaviorConfig): boolean {
|
|
119
252
|
return this.now() - envelope.fetchedAt < config.refreshTtlSeconds * 1000;
|
|
120
253
|
}
|
|
121
254
|
|
|
122
|
-
private
|
|
255
|
+
private loadFromCache(): Promise<void> {
|
|
256
|
+
return this.current ? Promise.resolve() : this.cacheRead.run(() => this.readCache());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async readCache(): Promise<void> {
|
|
123
260
|
if (this.current) return;
|
|
124
261
|
const stored = await this.cache.get(this.cacheKey).catch(() => null);
|
|
125
262
|
if (!stored) return;
|
|
126
263
|
try {
|
|
127
264
|
const envelope = JSON.parse(stored) as CacheEnvelope;
|
|
128
|
-
this.current = { config:
|
|
129
|
-
this.
|
|
265
|
+
this.current = { config: this.parse(envelope.configJson), envelope };
|
|
266
|
+
this.setState('ready-cache');
|
|
130
267
|
} catch {
|
|
131
268
|
// corrupted cache entries are ignored, not fatal
|
|
132
269
|
}
|
|
133
270
|
}
|
|
134
271
|
|
|
135
|
-
private
|
|
272
|
+
private fetchNetwork(): Promise<AppBehaviorConfig> {
|
|
273
|
+
return this.networkFetch.run(() => this.fetchFromNetwork());
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** One attempt; `conditional` sends the stored ETag when there is one. */
|
|
277
|
+
private async fetchOnce(
|
|
278
|
+
conditional: boolean,
|
|
279
|
+
): Promise<{ status: number; text: string; etag?: string }> {
|
|
136
280
|
const headers: Record<string, string> = {};
|
|
137
|
-
|
|
138
|
-
|
|
281
|
+
const etag = this.current?.envelope.etag;
|
|
282
|
+
if (conditional && etag) {
|
|
283
|
+
headers['If-None-Match'] = etag;
|
|
139
284
|
}
|
|
140
285
|
// Bounded: with no cache, a hung fetch used to block every chat and quota call.
|
|
141
|
-
|
|
286
|
+
return withTimeout(
|
|
142
287
|
this.deps.timeoutMs ?? DEFAULT_AI_TIMEOUTS.configMs,
|
|
143
288
|
async signal => {
|
|
144
|
-
const
|
|
145
|
-
return {
|
|
289
|
+
const response = await this.fetchFn(this.deps.configUrl, { headers, signal });
|
|
290
|
+
return {
|
|
291
|
+
status: response.status,
|
|
292
|
+
text: response.ok && response.status !== 304 ? await response.text() : '',
|
|
293
|
+
etag: response.headers.get('etag') ?? undefined,
|
|
294
|
+
};
|
|
146
295
|
},
|
|
147
296
|
() => new AiError('config-unavailable', 'config fetch timed out'),
|
|
148
297
|
);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async fetchFromNetwork(): Promise<AppBehaviorConfig> {
|
|
301
|
+
let result = await this.fetchOnce(true);
|
|
302
|
+
if (result.status === 304) {
|
|
303
|
+
if (this.current) {
|
|
304
|
+
this.current.envelope.fetchedAt = this.now();
|
|
305
|
+
await this.persist(this.current.envelope);
|
|
306
|
+
this.setState('ready-network');
|
|
307
|
+
return this.current.config;
|
|
308
|
+
}
|
|
309
|
+
// Not-modified against a cache that is no longer there (it was cleared,
|
|
310
|
+
// or it failed to parse). Asking again without the ETag is the answer;
|
|
311
|
+
// reporting `config fetch failed: 304` was not (AI-17).
|
|
312
|
+
result = await this.fetchOnce(false);
|
|
154
313
|
}
|
|
155
|
-
if (
|
|
156
|
-
throw new AiError('config-unavailable', `config fetch failed: ${
|
|
314
|
+
if (result.status < 200 || result.status >= 300) {
|
|
315
|
+
throw new AiError('config-unavailable', `config fetch failed: ${result.status}`);
|
|
157
316
|
}
|
|
158
|
-
const config =
|
|
317
|
+
const config = this.parse(result.text);
|
|
159
318
|
const envelope: CacheEnvelope = {
|
|
160
|
-
configJson,
|
|
161
|
-
etag:
|
|
319
|
+
configJson: result.text,
|
|
320
|
+
etag: result.etag,
|
|
162
321
|
fetchedAt: this.now(),
|
|
163
322
|
};
|
|
164
323
|
this.current = { config, envelope };
|
|
165
324
|
await this.persist(envelope);
|
|
166
|
-
this.
|
|
325
|
+
this.setState('ready-network');
|
|
167
326
|
return config;
|
|
168
327
|
}
|
|
169
328
|
|
|
@@ -185,16 +344,11 @@ export class BehaviorConfigManager {
|
|
|
185
344
|
return this.current.config;
|
|
186
345
|
}
|
|
187
346
|
try {
|
|
188
|
-
|
|
189
|
-
if (fetched) return fetched;
|
|
347
|
+
return await this.fetchNetwork();
|
|
190
348
|
} catch (e) {
|
|
191
349
|
if (this.current) return this.current.config; // stale beats broken
|
|
192
|
-
this.
|
|
350
|
+
this.setState('unavailable');
|
|
193
351
|
throw e instanceof AiError ? e : new AiError('config-unavailable', String(e));
|
|
194
352
|
}
|
|
195
|
-
// fetchNetwork only returns undefined when a 304 raced an empty cache.
|
|
196
|
-
if (this.current) return (this.current as { config: AppBehaviorConfig }).config;
|
|
197
|
-
this.state = 'unavailable';
|
|
198
|
-
throw new AiError('config-unavailable', 'no configuration available');
|
|
199
353
|
}
|
|
200
354
|
}
|
|
@@ -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';
|