@chipmobilesdk/rn-ai 0.2.0 → 0.3.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 +158 -55
- package/README.md +159 -151
- package/package.json +39 -43
- package/skills/sdk-rn-ai/SKILL.md +56 -57
- package/src/config/behaviorConfig.ts +233 -200
- package/src/core/aiClient.ts +354 -281
- package/src/diagnostics/errors.ts +89 -75
- package/src/diagnostics/messageKeys.ts +63 -27
- package/src/index.ts +31 -35
- package/src/react/hooks.ts +141 -134
- package/src/transport/sseClient.ts +143 -143
- package/src/transport/timeouts.ts +53 -53
package/src/core/aiClient.ts
CHANGED
|
@@ -1,281 +1,354 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
type
|
|
6
|
-
} from '
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
1
|
+
import {
|
|
2
|
+
createDiagnosticChannel,
|
|
3
|
+
createListenerSet,
|
|
4
|
+
realScheduler,
|
|
5
|
+
type Scheduler,
|
|
6
|
+
} from '@chipmobilesdk/rn-core';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
BehaviorConfigManager,
|
|
10
|
+
type AiConfigCache,
|
|
11
|
+
type AppBehaviorConfig,
|
|
12
|
+
type BehaviorState,
|
|
13
|
+
} from '../config/behaviorConfig';
|
|
14
|
+
import { AiError, errorFromHttp, type AiErrorCode } from '../diagnostics/errors';
|
|
15
|
+
import { requestSse, type SseHandle } from '../transport/sseClient';
|
|
16
|
+
import { DEFAULT_AI_TIMEOUTS, withTimeout, type AiTimeouts } from '../transport/timeouts';
|
|
17
|
+
|
|
18
|
+
/** Every failure a caller receives also goes to the app's SDK sinks (CORE-08). */
|
|
19
|
+
const channel = createDiagnosticChannel<AiErrorCode>('rn-ai');
|
|
20
|
+
|
|
21
|
+
function report(operation: 'chat' | 'quota', error: AiError): void {
|
|
22
|
+
channel.emit(error.code, error.retryable ? 'warn' : 'error', {
|
|
23
|
+
operation,
|
|
24
|
+
retryable: error.retryable,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Minimal slice of the rn-auth `Auth` controller the client needs. */
|
|
29
|
+
export interface TokenProvider {
|
|
30
|
+
requestAccessToken(resourceKey: string): Promise<{ status: string; token?: string }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ContentPart =
|
|
34
|
+
| { type: 'text'; text: string }
|
|
35
|
+
| { type: 'image'; mimeType: string; dataBase64: string }
|
|
36
|
+
| { type: 'audio'; format: 'wav' | 'mp3'; dataBase64: string };
|
|
37
|
+
|
|
38
|
+
export interface ChatMessage {
|
|
39
|
+
role: 'system' | 'user' | 'assistant';
|
|
40
|
+
content: ContentPart[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ChatRequest {
|
|
44
|
+
messages: ChatMessage[];
|
|
45
|
+
options?: { temperature?: number; maxOutputTokens?: number };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Usage {
|
|
49
|
+
promptTokens: number;
|
|
50
|
+
completionTokens: number;
|
|
51
|
+
totalTokens: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface QuotaSnapshot {
|
|
55
|
+
minuteRemaining: number;
|
|
56
|
+
dayRemaining: number;
|
|
57
|
+
dayTokensRemaining: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ChatStreamHandlers {
|
|
61
|
+
onDelta(text: string): void;
|
|
62
|
+
onDone(result: { usage: Usage; quota?: QuotaSnapshot }): void;
|
|
63
|
+
onError(error: AiError): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ChatHandle {
|
|
67
|
+
cancel(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AiBehaviorState {
|
|
71
|
+
config: BehaviorState;
|
|
72
|
+
aiEnabled: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AiClientConfig {
|
|
76
|
+
appId: string;
|
|
77
|
+
configUrl: string;
|
|
78
|
+
auth: TokenProvider;
|
|
79
|
+
/** rn-auth resource key configured for the gateway API scope. */
|
|
80
|
+
resourceKey?: string;
|
|
81
|
+
storage?: AiConfigCache;
|
|
82
|
+
fetchFn?: typeof fetch;
|
|
83
|
+
/** Test seam for the SSE transport. */
|
|
84
|
+
transport?: typeof requestSse;
|
|
85
|
+
/** Overrides for the network time limits; see `AiTimeouts` for the defaults. */
|
|
86
|
+
timeouts?: Partial<AiTimeouts>;
|
|
87
|
+
/** Test seam: how the backoff before a silent retry is waited out. */
|
|
88
|
+
scheduler?: Scheduler;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Ceiling on one remote-configured backoff step, so a bad document cannot stall a chat. */
|
|
92
|
+
const MAX_RETRY_BACKOFF_MS = 30_000;
|
|
93
|
+
|
|
94
|
+
/** The wait before silent retry `index` (0-based); the last configured step repeats. */
|
|
95
|
+
function backoffBefore(steps: readonly number[], index: number): number {
|
|
96
|
+
const step = steps[Math.min(index, steps.length - 1)] ?? 0;
|
|
97
|
+
return Math.min(Math.max(0, step), MAX_RETRY_BACKOFF_MS);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface AiClient {
|
|
101
|
+
chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle;
|
|
102
|
+
getQuota(): Promise<QuotaSnapshot>;
|
|
103
|
+
/** The current behavior state; the same object until it changes. */
|
|
104
|
+
getBehaviorState(): AiBehaviorState;
|
|
105
|
+
/**
|
|
106
|
+
* Called whenever the behavior state changes (AI-09); returns the unsubscribe.
|
|
107
|
+
* Optional so an app's own `AiClient` implementation keeps compiling.
|
|
108
|
+
*/
|
|
109
|
+
subscribe?(listener: () => void): () => void;
|
|
110
|
+
reloadConfig(): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface StreamEvent {
|
|
114
|
+
type?: string;
|
|
115
|
+
text?: string;
|
|
116
|
+
usage?: Usage;
|
|
117
|
+
quota?: QuotaSnapshot;
|
|
118
|
+
code?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createAiClient(clientConfig: AiClientConfig): AiClient {
|
|
122
|
+
const resourceKey = clientConfig.resourceKey ?? 'ai-gateway';
|
|
123
|
+
const transport = clientConfig.transport ?? requestSse;
|
|
124
|
+
const fetchFn = clientConfig.fetchFn ?? fetch;
|
|
125
|
+
const timeouts: AiTimeouts = { ...DEFAULT_AI_TIMEOUTS, ...clientConfig.timeouts };
|
|
126
|
+
const scheduler = clientConfig.scheduler ?? realScheduler;
|
|
127
|
+
const configManager = new BehaviorConfigManager({
|
|
128
|
+
configUrl: clientConfig.configUrl,
|
|
129
|
+
appId: clientConfig.appId,
|
|
130
|
+
cache: clientConfig.storage,
|
|
131
|
+
fetchFn: clientConfig.fetchFn,
|
|
132
|
+
timeoutMs: timeouts.configMs,
|
|
133
|
+
});
|
|
134
|
+
let lastConfig: AppBehaviorConfig | undefined;
|
|
135
|
+
const behaviorListeners = createListenerSet<[]>();
|
|
136
|
+
let snapshot: AiBehaviorState | undefined;
|
|
137
|
+
|
|
138
|
+
/** Rebuilt only when a field changes, so useSyncExternalStore sees a stable object. */
|
|
139
|
+
function behaviorState(): AiBehaviorState {
|
|
140
|
+
const config = configManager.state;
|
|
141
|
+
const aiEnabled = lastConfig?.features.aiEnabled ?? true;
|
|
142
|
+
if (!snapshot || snapshot.config !== config || snapshot.aiEnabled !== aiEnabled) {
|
|
143
|
+
snapshot = { config, aiEnabled };
|
|
144
|
+
}
|
|
145
|
+
return snapshot;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function setLastConfig(next: AppBehaviorConfig): void {
|
|
149
|
+
const wasEnabled = lastConfig?.features.aiEnabled ?? true;
|
|
150
|
+
lastConfig = next;
|
|
151
|
+
if (next.features.aiEnabled !== wasEnabled) behaviorListeners.notify();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function acquire(): Promise<{ config: AppBehaviorConfig; token: string }> {
|
|
155
|
+
const config = await configManager.getConfig();
|
|
156
|
+
setLastConfig(config);
|
|
157
|
+
if (!config.features.aiEnabled) {
|
|
158
|
+
throw new AiError('config-unavailable', 'AI is disabled for this app', {
|
|
159
|
+
recoverable: false,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const tokenResult = await clientConfig.auth.requestAccessToken(resourceKey);
|
|
163
|
+
if (tokenResult.status !== 'success' || !tokenResult.token) {
|
|
164
|
+
throw new AiError('auth-invalid', 'could not acquire an access token');
|
|
165
|
+
}
|
|
166
|
+
return { config, token: tokenResult.token };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const chatUrl = (config: AppBehaviorConfig) =>
|
|
170
|
+
`${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/chat`;
|
|
171
|
+
|
|
172
|
+
function chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle {
|
|
173
|
+
let cancelled = false;
|
|
174
|
+
let settled = false;
|
|
175
|
+
let inner: SseHandle | undefined;
|
|
176
|
+
|
|
177
|
+
// Every callback to the app goes through here. Nothing is delivered after
|
|
178
|
+
// cancel() — an acquire() or config reload that failed after the user
|
|
179
|
+
// cancelled (or sent the next message) used to flip that screen to
|
|
180
|
+
// `error` — and exactly one terminal callback is delivered.
|
|
181
|
+
const emit = {
|
|
182
|
+
delta(text: string) {
|
|
183
|
+
if (!cancelled && !settled) handlers.onDelta(text);
|
|
184
|
+
},
|
|
185
|
+
done(result: { usage: Usage; quota?: QuotaSnapshot }) {
|
|
186
|
+
if (cancelled || settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
handlers.onDone(result);
|
|
189
|
+
},
|
|
190
|
+
error(error: AiError) {
|
|
191
|
+
if (cancelled || settled) return;
|
|
192
|
+
settled = true;
|
|
193
|
+
inner?.cancel();
|
|
194
|
+
report('chat', error);
|
|
195
|
+
handlers.onError(error);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Any exception on the way becomes a reported error. Each path used to end
|
|
200
|
+
// in `.catch(() => undefined)`, so a transport that threw (a bad URL from
|
|
201
|
+
// remote config, say) left the UI in `loading` with nothing to show.
|
|
202
|
+
const fail = (e: unknown) =>
|
|
203
|
+
emit.error(
|
|
204
|
+
e instanceof AiError
|
|
205
|
+
? e
|
|
206
|
+
: new AiError('internal', e instanceof Error ? e.message : String(e)),
|
|
207
|
+
);
|
|
208
|
+
const guarded =
|
|
209
|
+
<A extends unknown[]>(fn: (...args: A) => void) =>
|
|
210
|
+
(...args: A) => {
|
|
211
|
+
try {
|
|
212
|
+
fn(...args);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
fail(e);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const attempt = async (state: { configReloads: number; authRetries: number }) => {
|
|
219
|
+
const { config, token } = await acquire();
|
|
220
|
+
if (cancelled) return;
|
|
221
|
+
|
|
222
|
+
let done: { usage: Usage; quota?: QuotaSnapshot } | undefined;
|
|
223
|
+
let streamErrorCode: string | undefined;
|
|
224
|
+
inner = transport(
|
|
225
|
+
{
|
|
226
|
+
url: chatUrl(config),
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: {
|
|
229
|
+
Authorization: `Bearer ${token}`,
|
|
230
|
+
'Content-Type': 'application/json',
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify(request),
|
|
233
|
+
connectTimeoutMs: timeouts.connectMs,
|
|
234
|
+
idleTimeoutMs: timeouts.idleMs,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
onData: guarded(payload => {
|
|
238
|
+
if (payload === '[DONE]') return;
|
|
239
|
+
let event: StreamEvent;
|
|
240
|
+
try {
|
|
241
|
+
event = JSON.parse(payload) as StreamEvent;
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (event.type === 'delta' && typeof event.text === 'string') {
|
|
246
|
+
emit.delta(event.text);
|
|
247
|
+
} else if (event.type === 'done' && event.usage) {
|
|
248
|
+
done = { usage: event.usage, quota: event.quota };
|
|
249
|
+
} else if (event.type === 'error') {
|
|
250
|
+
streamErrorCode = event.code ?? 'UPSTREAM_ERROR';
|
|
251
|
+
}
|
|
252
|
+
}),
|
|
253
|
+
onEnd: guarded(() => {
|
|
254
|
+
if (streamErrorCode) {
|
|
255
|
+
emit.error(new AiError('upstream-error', 'the AI service failed mid-response'));
|
|
256
|
+
} else if (done) {
|
|
257
|
+
emit.done(done);
|
|
258
|
+
} else {
|
|
259
|
+
emit.error(new AiError('internal', 'stream ended without completion'));
|
|
260
|
+
}
|
|
261
|
+
}),
|
|
262
|
+
onError: guarded((status, bodyText) => {
|
|
263
|
+
if (cancelled) return;
|
|
264
|
+
if (status === 0 && bodyText === 'timeout') {
|
|
265
|
+
emit.error(
|
|
266
|
+
new AiError('upstream-error', 'the AI service stopped responding', {
|
|
267
|
+
recoverable: true,
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// Designated config-stale signals → silent reload + retry once (FR-018).
|
|
273
|
+
if (
|
|
274
|
+
config.recovery.reloadOnHttpStatus.includes(status) &&
|
|
275
|
+
state.configReloads < config.recovery.maxSilentRetries
|
|
276
|
+
) {
|
|
277
|
+
// Waits `recovery.retryBackoffMs` first (AI-05): a gateway mid-rollout
|
|
278
|
+
// that answered 410 a moment ago may well answer it again at once.
|
|
279
|
+
const retryAfter = backoffBefore(config.recovery.retryBackoffMs, state.configReloads);
|
|
280
|
+
configManager.getConfig({ forceReload: true }).then(
|
|
281
|
+
() => {
|
|
282
|
+
scheduler.setTimeout(() => {
|
|
283
|
+
if (cancelled) return;
|
|
284
|
+
attempt({ ...state, configReloads: state.configReloads + 1 }).catch(fail);
|
|
285
|
+
}, retryAfter);
|
|
286
|
+
},
|
|
287
|
+
e => fail(e instanceof AiError ? e : new AiError('config-unavailable', String(e))),
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Expired server-side session → one fresh-token retry.
|
|
292
|
+
if (status === 401 && state.authRetries < 1) {
|
|
293
|
+
attempt({ ...state, authRetries: state.authRetries + 1 }).catch(fail);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
emit.error(errorFromHttp(status, bodyText));
|
|
297
|
+
}),
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
attempt({ configReloads: 0, authRetries: 0 }).catch(fail);
|
|
303
|
+
return {
|
|
304
|
+
cancel() {
|
|
305
|
+
cancelled = true;
|
|
306
|
+
inner?.cancel();
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function getQuota(): Promise<QuotaSnapshot> {
|
|
312
|
+
try {
|
|
313
|
+
return await fetchQuota();
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error instanceof AiError) report('quota', error);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function fetchQuota(): Promise<QuotaSnapshot> {
|
|
321
|
+
const { config, token } = await acquire();
|
|
322
|
+
return withTimeout(
|
|
323
|
+
timeouts.quotaMs,
|
|
324
|
+
async signal => {
|
|
325
|
+
const response = await fetchFn(
|
|
326
|
+
`${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/quota`,
|
|
327
|
+
{ headers: { Authorization: `Bearer ${token}` }, signal },
|
|
328
|
+
);
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
throw errorFromHttp(response.status, await response.text());
|
|
331
|
+
}
|
|
332
|
+
return (await response.json()) as QuotaSnapshot;
|
|
333
|
+
},
|
|
334
|
+
() => new AiError('upstream-error', 'quota request timed out', { recoverable: true }),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
chat,
|
|
340
|
+
getQuota,
|
|
341
|
+
getBehaviorState: () => behaviorState(),
|
|
342
|
+
subscribe: listener => {
|
|
343
|
+
const fromManager = configManager.subscribe(listener);
|
|
344
|
+
const fromClient = behaviorListeners.add(listener);
|
|
345
|
+
return () => {
|
|
346
|
+
fromManager();
|
|
347
|
+
fromClient();
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
reloadConfig: async () => {
|
|
351
|
+
setLastConfig(await configManager.getConfig({ forceReload: true }));
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|