@jxburros/ai-nugget 0.3.1
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/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/adapters/engines/anthropic.d.ts +8 -0
- package/dist/adapters/engines/anthropic.js +278 -0
- package/dist/adapters/engines/base.d.ts +23 -0
- package/dist/adapters/engines/base.js +93 -0
- package/dist/adapters/engines/google.d.ts +8 -0
- package/dist/adapters/engines/google.js +217 -0
- package/dist/adapters/engines/ollama.d.ts +10 -0
- package/dist/adapters/engines/ollama.js +160 -0
- package/dist/adapters/engines/openaiChat.d.ts +15 -0
- package/dist/adapters/engines/openaiChat.js +228 -0
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/profiles.d.ts +42 -0
- package/dist/adapters/profiles.js +111 -0
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.js +3 -0
- package/dist/agent/loop.d.ts +70 -0
- package/dist/agent/loop.js +263 -0
- package/dist/agent/tools.d.ts +29 -0
- package/dist/agent/tools.js +48 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +80 -0
- package/dist/handler.d.ts +48 -0
- package/dist/handler.js +371 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json.d.ts +7 -0
- package/dist/json.js +114 -0
- package/dist/keys.d.ts +7 -0
- package/dist/keys.js +78 -0
- package/dist/policy.d.ts +6 -0
- package/dist/policy.js +37 -0
- package/dist/redact.d.ts +8 -0
- package/dist/redact.js +46 -0
- package/dist/tokens.d.ts +5 -0
- package/dist/tokens.js +18 -0
- package/dist/transport.d.ts +28 -0
- package/dist/transport.js +145 -0
- package/dist/types.d.ts +191 -0
- package/dist/types.js +4 -0
- package/dist/util.d.ts +8 -0
- package/dist/util.js +51 -0
- package/nugget/VERSION.txt +2 -0
- package/nugget/src/adapters/engines/anthropic.ts +260 -0
- package/nugget/src/adapters/engines/base.ts +90 -0
- package/nugget/src/adapters/engines/google.ts +209 -0
- package/nugget/src/adapters/engines/ollama.ts +153 -0
- package/nugget/src/adapters/engines/openaiChat.ts +223 -0
- package/nugget/src/adapters/index.ts +17 -0
- package/nugget/src/adapters/profiles.ts +149 -0
- package/nugget/src/agent/index.ts +2 -0
- package/nugget/src/agent/loop.ts +314 -0
- package/nugget/src/agent/tools.ts +55 -0
- package/nugget/src/errors.ts +80 -0
- package/nugget/src/handler.ts +408 -0
- package/nugget/src/index.ts +10 -0
- package/nugget/src/json.ts +108 -0
- package/nugget/src/keys.ts +70 -0
- package/nugget/src/policy.ts +39 -0
- package/nugget/src/redact.ts +48 -0
- package/nugget/src/tokens.ts +21 -0
- package/nugget/src/transport.ts +148 -0
- package/nugget/src/types.ts +175 -0
- package/nugget/src/util.ts +51 -0
- package/package.json +69 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { adapterFor } from './adapters/index.js';
|
|
2
|
+
import { applyAuth, profileFor } from './adapters/profiles.js';
|
|
3
|
+
import { AIError, fromUnknown } from './errors.js';
|
|
4
|
+
import { allowAllPolicy } from './policy.js';
|
|
5
|
+
import { SessionRedactor } from './redact.js';
|
|
6
|
+
import type {
|
|
7
|
+
CallInfo,
|
|
8
|
+
CallRecord,
|
|
9
|
+
ChatRequest,
|
|
10
|
+
ChatResult,
|
|
11
|
+
Connection,
|
|
12
|
+
GovernancePolicy,
|
|
13
|
+
KeySource,
|
|
14
|
+
ModelInfo,
|
|
15
|
+
Redactor,
|
|
16
|
+
ResolvedConnection,
|
|
17
|
+
StreamEvent,
|
|
18
|
+
TelemetrySink,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
import { promptChars, sleep } from './util.js';
|
|
21
|
+
|
|
22
|
+
type QueueWaiter = {
|
|
23
|
+
resolve: () => void;
|
|
24
|
+
reject: (error: unknown) => void;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
onAbort: () => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface HandlerOptions {
|
|
30
|
+
keySource: KeySource;
|
|
31
|
+
policy?: GovernancePolicy;
|
|
32
|
+
redactor?: Redactor;
|
|
33
|
+
telemetry?: TelemetrySink;
|
|
34
|
+
hooks?: {
|
|
35
|
+
beforeCall?(info: CallInfo): Promise<void | 'deny'>;
|
|
36
|
+
afterCall?(record: CallRecord): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
retry?: { maxAttempts?: number; baseDelayMs?: number; maxDelayMs?: number };
|
|
39
|
+
limits?: { maxConcurrent?: number; minIntervalMs?: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class AIHandler {
|
|
43
|
+
private active = 0;
|
|
44
|
+
private queue: QueueWaiter[] = [];
|
|
45
|
+
private lastStarted = 0;
|
|
46
|
+
private sessionRedactor = new SessionRedactor();
|
|
47
|
+
private policy: GovernancePolicy;
|
|
48
|
+
|
|
49
|
+
constructor(private opts: HandlerOptions) {
|
|
50
|
+
this.policy = opts.policy ?? allowAllPolicy();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async chat(conn: Connection, req: ChatRequest): Promise<ChatResult> {
|
|
54
|
+
let result: ChatResult | undefined;
|
|
55
|
+
for await (const event of this.stream(conn, req)) {
|
|
56
|
+
if (event.type === 'done') result = event.result;
|
|
57
|
+
if (event.type === 'error') throw event.error;
|
|
58
|
+
}
|
|
59
|
+
if (!result) throw new AIError('Call ended without a result', { kind: 'invalid_response', provider: conn.provider });
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async *stream(conn: Connection, req: ChatRequest): AsyncIterable<StreamEvent> {
|
|
64
|
+
const callId = createCallId();
|
|
65
|
+
const startedAt = Date.now();
|
|
66
|
+
const adapter = adapterFor(conn.provider, conn.baseUrl);
|
|
67
|
+
const policyResult = this.policy.checkModel(conn.provider, req.model);
|
|
68
|
+
if (!policyResult.allowed) {
|
|
69
|
+
const error = new AIError(policyResult.reason, { kind: 'policy_blocked', retryable: false, provider: conn.provider });
|
|
70
|
+
await this.recordFailure(callId, conn, req, startedAt, 1, error);
|
|
71
|
+
yield { type: 'error', error: this.redactedError(error) };
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let resolved: ResolvedConnection;
|
|
76
|
+
try {
|
|
77
|
+
resolved = await this.resolveConnection(conn);
|
|
78
|
+
} catch (errorValue) {
|
|
79
|
+
// Key resolution failures (missing/locked/denied) are a recorded outcome,
|
|
80
|
+
// not an uncaught throw — the traceability contract covers them too.
|
|
81
|
+
const error = fromUnknown(errorValue, conn.provider);
|
|
82
|
+
await this.recordFailure(callId, conn, req, startedAt, 1, error);
|
|
83
|
+
yield { type: 'error', error: this.redactedError(error) };
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const info: CallInfo = { callId, connection: conn, provider: conn.provider, model: req.model, metadata: req.metadata };
|
|
87
|
+
try {
|
|
88
|
+
if (await this.opts.hooks?.beforeCall?.(info) === 'deny') {
|
|
89
|
+
const error = new AIError('Call denied by beforeCall hook', { kind: 'policy_blocked', retryable: false, provider: conn.provider });
|
|
90
|
+
await this.recordFailure(callId, conn, req, startedAt, 1, error);
|
|
91
|
+
yield { type: 'error', error: this.redactedError(error) };
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
} catch (errorValue) {
|
|
95
|
+
// A throwing beforeCall hook is a recorded, redacted outcome too — not
|
|
96
|
+
// an uncaught throw that skips telemetry and the redaction guarantee.
|
|
97
|
+
const error = fromUnknown(errorValue, conn.provider);
|
|
98
|
+
await this.recordFailure(callId, conn, req, startedAt, 1, error);
|
|
99
|
+
yield { type: 'error', error: this.redactedError(error) };
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let acquired = false;
|
|
104
|
+
let recorded = false;
|
|
105
|
+
let attempt = 0;
|
|
106
|
+
try {
|
|
107
|
+
await this.acquire(req.signal);
|
|
108
|
+
acquired = true;
|
|
109
|
+
const maxAttempts = this.opts.retry?.maxAttempts ?? 3;
|
|
110
|
+
let emittedStart = false;
|
|
111
|
+
let emittedOutput = false;
|
|
112
|
+
for (;;) {
|
|
113
|
+
attempt += 1;
|
|
114
|
+
try {
|
|
115
|
+
for await (const event of adapter.stream(resolved, req)) {
|
|
116
|
+
if (event.type === 'start') {
|
|
117
|
+
if (!emittedStart) {
|
|
118
|
+
emittedStart = true;
|
|
119
|
+
yield { ...event, callId };
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (event.type === 'delta' || event.type === 'tool_call') emittedOutput = true;
|
|
124
|
+
if (event.type === 'done') {
|
|
125
|
+
await this.recordSuccess(callId, conn, req, startedAt, attempt, event.result);
|
|
126
|
+
recorded = true;
|
|
127
|
+
yield event;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
yield event;
|
|
131
|
+
}
|
|
132
|
+
throw new AIError('Provider did not emit a done event', { kind: 'invalid_response', provider: conn.provider });
|
|
133
|
+
} catch (errorValue) {
|
|
134
|
+
const error = fromUnknown(errorValue, conn.provider);
|
|
135
|
+
if (!error.retryable || attempt >= maxAttempts || req.signal?.aborted || emittedOutput) {
|
|
136
|
+
await this.recordFailure(callId, conn, req, startedAt, attempt, error);
|
|
137
|
+
recorded = true;
|
|
138
|
+
yield { type: 'error', error: this.redactedError(error) };
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const delayMs = this.retryDelay(error, attempt);
|
|
142
|
+
yield { type: 'retry', attempt, reason: error.kind, delayMs };
|
|
143
|
+
try {
|
|
144
|
+
await sleep(delayMs, req.signal);
|
|
145
|
+
} catch (sleepError) {
|
|
146
|
+
const canceled = fromUnknown(sleepError, conn.provider);
|
|
147
|
+
await this.recordFailure(callId, conn, req, startedAt, attempt, canceled);
|
|
148
|
+
recorded = true;
|
|
149
|
+
yield { type: 'error', error: this.redactedError(canceled) };
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (errorValue) {
|
|
155
|
+
const error = fromUnknown(errorValue, conn.provider);
|
|
156
|
+
await this.recordFailure(callId, conn, req, startedAt, Math.max(1, attempt), error);
|
|
157
|
+
recorded = true;
|
|
158
|
+
yield { type: 'error', error: this.redactedError(error) };
|
|
159
|
+
return;
|
|
160
|
+
} finally {
|
|
161
|
+
if (acquired) this.release();
|
|
162
|
+
if (acquired && !recorded) {
|
|
163
|
+
await this.recordFailure(
|
|
164
|
+
callId,
|
|
165
|
+
conn,
|
|
166
|
+
req,
|
|
167
|
+
startedAt,
|
|
168
|
+
Math.max(1, attempt),
|
|
169
|
+
new AIError('Call canceled before completion', { kind: 'canceled', retryable: false, provider: conn.provider }),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async listModels(conn: Connection): Promise<ModelInfo[]> {
|
|
176
|
+
return this.runProbe(conn, '__listModels__', async (resolved) => {
|
|
177
|
+
const adapter = adapterFor(conn.provider, conn.baseUrl);
|
|
178
|
+
return adapter.listModels?.(resolved) ?? [];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async testConnection(conn: Connection): Promise<{ ok: boolean; message: string }> {
|
|
183
|
+
try {
|
|
184
|
+
return await this.runProbe(conn, '__testConnection__', async (resolved) => {
|
|
185
|
+
const adapter = adapterFor(conn.provider, conn.baseUrl);
|
|
186
|
+
const health = await adapter.health?.(resolved);
|
|
187
|
+
if (health) {
|
|
188
|
+
if (!health.ok) throw new AIError(health.detail ?? 'Connection failed', { kind: 'network', retryable: false, provider: conn.provider });
|
|
189
|
+
return { ok: true, message: health.detail ?? 'Connection healthy' };
|
|
190
|
+
}
|
|
191
|
+
await adapter.listModels?.(resolved);
|
|
192
|
+
return { ok: true, message: 'Connection healthy' };
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
return { ok: false, message: error instanceof Error ? this.redact(error.message) : 'Connection failed' };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async runProbe<T>(conn: Connection, operation: '__listModels__' | '__testConnection__', action: (resolved: ResolvedConnection) => Promise<T>): Promise<T> {
|
|
200
|
+
const callId = createCallId();
|
|
201
|
+
const startedAt = Date.now();
|
|
202
|
+
const req: ChatRequest = { model: operation, messages: [], metadata: { operation } };
|
|
203
|
+
const policyResult = this.policy.checkModel(conn.provider, operation);
|
|
204
|
+
if (!policyResult.allowed) {
|
|
205
|
+
const error = new AIError(policyResult.reason, { kind: 'policy_blocked', retryable: false, provider: conn.provider });
|
|
206
|
+
await this.recordFailure(callId, conn, req, startedAt, 1, error);
|
|
207
|
+
throw this.redactedError(error);
|
|
208
|
+
}
|
|
209
|
+
let resolved: ResolvedConnection;
|
|
210
|
+
try {
|
|
211
|
+
resolved = await this.resolveConnection(conn);
|
|
212
|
+
const info: CallInfo = { callId, connection: conn, provider: conn.provider, model: operation, metadata: req.metadata };
|
|
213
|
+
if (await this.opts.hooks?.beforeCall?.(info) === 'deny') {
|
|
214
|
+
throw new AIError('Call denied by beforeCall hook', { kind: 'policy_blocked', retryable: false, provider: conn.provider });
|
|
215
|
+
}
|
|
216
|
+
await this.acquire(undefined);
|
|
217
|
+
try {
|
|
218
|
+
const value = await action(resolved);
|
|
219
|
+
await this.recordSuccess(callId, conn, req, startedAt, 1, {
|
|
220
|
+
text: '',
|
|
221
|
+
finishReason: 'stop',
|
|
222
|
+
usage: { estimated: true },
|
|
223
|
+
timing: { firstTokenMs: null, totalMs: Date.now() - startedAt },
|
|
224
|
+
model: operation,
|
|
225
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: resolved.baseUrl },
|
|
226
|
+
});
|
|
227
|
+
return value;
|
|
228
|
+
} finally {
|
|
229
|
+
this.release();
|
|
230
|
+
}
|
|
231
|
+
} catch (errorValue) {
|
|
232
|
+
const error = fromUnknown(errorValue, conn.provider);
|
|
233
|
+
await this.recordFailure(callId, conn, req, startedAt, 1, error);
|
|
234
|
+
throw this.redactedError(error);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async resolveConnection(conn: Connection): Promise<ResolvedConnection> {
|
|
239
|
+
const profile = profileFor(conn.provider, conn.baseUrl);
|
|
240
|
+
const baseUrl = conn.baseUrl ?? profile.defaultBaseUrl;
|
|
241
|
+
if (!baseUrl) throw new AIError(`Provider ${conn.provider} requires baseUrl`, { kind: 'invalid_request', retryable: false, provider: conn.provider });
|
|
242
|
+
const keyRef = conn.keyRef ?? { kind: 'none' as const };
|
|
243
|
+
const key = await this.opts.keySource.resolve(keyRef);
|
|
244
|
+
if (!key.ok) {
|
|
245
|
+
throw new AIError(`API key unavailable: ${key.reason}`, { kind: 'key_unavailable', retryable: false, provider: conn.provider });
|
|
246
|
+
}
|
|
247
|
+
this.sessionRedactor.addSecret(key.apiKey);
|
|
248
|
+
return {
|
|
249
|
+
...conn,
|
|
250
|
+
baseUrl: trimSlash(baseUrl),
|
|
251
|
+
apiKey: key.apiKey,
|
|
252
|
+
headers: applyAuth(profile, key.apiKey, conn.headers ?? {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async acquire(signal?: AbortSignal): Promise<void> {
|
|
257
|
+
const max = this.opts.limits?.maxConcurrent ?? Number.POSITIVE_INFINITY;
|
|
258
|
+
while (this.active >= max) {
|
|
259
|
+
await new Promise<void>((resolve, reject) => {
|
|
260
|
+
let waiter: QueueWaiter;
|
|
261
|
+
const onAbort = () => {
|
|
262
|
+
const index = this.queue.indexOf(waiter);
|
|
263
|
+
if (index >= 0) this.queue.splice(index, 1);
|
|
264
|
+
reject(new AIError('Call canceled while waiting for concurrency slot', { kind: 'canceled', retryable: false }));
|
|
265
|
+
};
|
|
266
|
+
if (signal?.aborted) {
|
|
267
|
+
reject(new AIError('Call canceled while waiting for concurrency slot', { kind: 'canceled', retryable: false }));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
271
|
+
waiter = { resolve, reject, signal, onAbort };
|
|
272
|
+
this.queue.push(waiter);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
this.active += 1;
|
|
276
|
+
const minInterval = this.opts.limits?.minIntervalMs ?? 0;
|
|
277
|
+
const wait = Math.max(0, this.lastStarted + minInterval - Date.now());
|
|
278
|
+
this.lastStarted = Date.now() + wait;
|
|
279
|
+
try {
|
|
280
|
+
if (wait) await sleep(wait, signal);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
this.release();
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private release(): void {
|
|
288
|
+
this.active = Math.max(0, this.active - 1);
|
|
289
|
+
while (this.queue.length) {
|
|
290
|
+
const waiter = this.queue.shift()!;
|
|
291
|
+
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
|
292
|
+
if (waiter.signal?.aborted) continue;
|
|
293
|
+
waiter.resolve();
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private retryDelay(error: AIError, attempt: number): number {
|
|
299
|
+
const base = this.opts.retry?.baseDelayMs ?? 250;
|
|
300
|
+
const max = this.opts.retry?.maxDelayMs ?? 30_000;
|
|
301
|
+
if (error.retryAfterMs !== undefined) return Math.min(error.retryAfterMs, max);
|
|
302
|
+
const exponential = Math.min(max, base * 2 ** Math.max(0, attempt - 1));
|
|
303
|
+
return Math.round(exponential * (0.75 + Math.random() * 0.5));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async recordSuccess(callId: string, conn: Connection, req: ChatRequest, startedAt: number, attempts: number, result: ChatResult): Promise<void> {
|
|
307
|
+
await this.record({
|
|
308
|
+
callId,
|
|
309
|
+
connectionId: conn.id,
|
|
310
|
+
provider: conn.provider,
|
|
311
|
+
model: req.model,
|
|
312
|
+
startedAt,
|
|
313
|
+
timing: result.timing,
|
|
314
|
+
usage: result.usage,
|
|
315
|
+
finishReason: result.finishReason,
|
|
316
|
+
attempts,
|
|
317
|
+
metadata: req.metadata,
|
|
318
|
+
promptChars: promptChars(req.messages),
|
|
319
|
+
responseChars: result.text.length,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async recordFailure(callId: string, conn: Connection, req: ChatRequest, startedAt: number, attempts: number, error: AIError): Promise<void> {
|
|
324
|
+
await this.record({
|
|
325
|
+
callId,
|
|
326
|
+
connectionId: conn.id,
|
|
327
|
+
provider: conn.provider,
|
|
328
|
+
model: req.model,
|
|
329
|
+
startedAt,
|
|
330
|
+
timing: { firstTokenMs: null, totalMs: Date.now() - startedAt },
|
|
331
|
+
usage: { estimated: true },
|
|
332
|
+
finishReason: error.kind === 'canceled' ? 'canceled' : 'error',
|
|
333
|
+
error: { kind: error.kind, status: error.status, message: error.message },
|
|
334
|
+
attempts,
|
|
335
|
+
metadata: req.metadata,
|
|
336
|
+
promptChars: promptChars(req.messages),
|
|
337
|
+
responseChars: 0,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private async record(record: CallRecord): Promise<void> {
|
|
342
|
+
const redacted = redactRecord(record, (text) => this.redact(text));
|
|
343
|
+
try {
|
|
344
|
+
await this.opts.telemetry?.record(redacted);
|
|
345
|
+
await this.opts.hooks?.afterCall?.(redacted);
|
|
346
|
+
} catch {
|
|
347
|
+
// Telemetry and afterCall hooks must not re-drive provider calls or turn
|
|
348
|
+
// an already-completed model response into a retry.
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private redact(text: string): string {
|
|
353
|
+
return (this.opts.redactor ?? this.sessionRedactor).redact(this.sessionRedactor.redact(text));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private redactedError(error: AIError): AIError {
|
|
357
|
+
return new AIError(this.redact(error.message), {
|
|
358
|
+
kind: error.kind,
|
|
359
|
+
status: error.status,
|
|
360
|
+
retryable: error.retryable,
|
|
361
|
+
provider: error.provider,
|
|
362
|
+
raw: error.raw === undefined ? undefined : this.redact(error.raw),
|
|
363
|
+
retryAfterMs: error.retryAfterMs,
|
|
364
|
+
cause: this.redactedCause(error.cause),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private redactedCause(cause: unknown): unknown {
|
|
369
|
+
if (cause instanceof Error) {
|
|
370
|
+
const redacted = new Error(this.redact(cause.message));
|
|
371
|
+
redacted.name = cause.name;
|
|
372
|
+
redacted.stack = undefined;
|
|
373
|
+
return redacted;
|
|
374
|
+
}
|
|
375
|
+
return cause;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function redactRecord(record: CallRecord, redact: (text: string) => string): CallRecord {
|
|
380
|
+
return {
|
|
381
|
+
...record,
|
|
382
|
+
error: record.error ? { ...record.error, message: redact(record.error.message) } : undefined,
|
|
383
|
+
metadata: redactMetadata(record.metadata, redact),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Redacts metadata by round-tripping through JSON. Metadata is an app-supplied
|
|
389
|
+
* passthrough and may contain values JSON cannot represent (BigInt, circular
|
|
390
|
+
* references, functions); redaction must never throw, so a non-serializable
|
|
391
|
+
* payload is replaced with a sentinel rather than crashing the telemetry path.
|
|
392
|
+
*/
|
|
393
|
+
function redactMetadata(metadata: Record<string, unknown> | undefined, redact: (text: string) => string): Record<string, unknown> | undefined {
|
|
394
|
+
if (!metadata) return undefined;
|
|
395
|
+
try {
|
|
396
|
+
return JSON.parse(redact(JSON.stringify(metadata))) as Record<string, unknown>;
|
|
397
|
+
} catch {
|
|
398
|
+
return { redacted: true, note: 'metadata was not JSON-serializable' };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function trimSlash(value: string): string {
|
|
403
|
+
return value.replace(/\/+$/, '');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function createCallId(): string {
|
|
407
|
+
return globalThis.crypto?.randomUUID?.() ?? `call_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
408
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './errors.js';
|
|
3
|
+
export * from './transport.js';
|
|
4
|
+
export * from './json.js';
|
|
5
|
+
export * from './tokens.js';
|
|
6
|
+
export * from './keys.js';
|
|
7
|
+
export * from './redact.js';
|
|
8
|
+
export * from './policy.js';
|
|
9
|
+
export * from './handler.js';
|
|
10
|
+
export * from './adapters/index.js';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { AIError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export function extractJson(text: string): unknown | null {
|
|
4
|
+
const trimmed = text.trim();
|
|
5
|
+
if (!trimmed) return null;
|
|
6
|
+
const direct = tryParse(trimmed);
|
|
7
|
+
if (direct.ok) return direct.value;
|
|
8
|
+
|
|
9
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
|
10
|
+
if (fenced?.[1]) {
|
|
11
|
+
const parsed = tryParse(fenced[1].trim());
|
|
12
|
+
if (parsed.ok) return parsed.value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const [open, close] of [['{', '}'], ['[', ']']] as const) {
|
|
16
|
+
for (const span of balancedSpans(text, open, close)) {
|
|
17
|
+
const parsed = tryParse(span);
|
|
18
|
+
if (parsed.ok) return parsed.value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Yields each top-level `open`/`close` balanced substring of `text`, tracking
|
|
26
|
+
* nesting depth and skipping over string-literal content (including escapes)
|
|
27
|
+
* so a stray brace inside a string or trailing prose can't prematurely close
|
|
28
|
+
* or extend a span. Used instead of first-`indexOf`/last-`lastIndexOf` so
|
|
29
|
+
* multiple JSON regions (or JSON followed by unrelated braces) don't get
|
|
30
|
+
* spliced into one bogus span.
|
|
31
|
+
*/
|
|
32
|
+
function* balancedSpans(text: string, open: string, close: string): Generator<string> {
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let spanStart = -1;
|
|
35
|
+
let inString = false;
|
|
36
|
+
let escaped = false;
|
|
37
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
38
|
+
const ch = text[i];
|
|
39
|
+
if (inString) {
|
|
40
|
+
if (escaped) escaped = false;
|
|
41
|
+
else if (ch === '\\') escaped = true;
|
|
42
|
+
else if (ch === '"') inString = false;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (ch === '"') {
|
|
46
|
+
inString = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ch === open) {
|
|
50
|
+
if (depth === 0) spanStart = i;
|
|
51
|
+
depth += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === close && depth > 0) {
|
|
55
|
+
depth -= 1;
|
|
56
|
+
if (depth === 0 && spanStart >= 0) {
|
|
57
|
+
yield text.slice(spanStart, i + 1);
|
|
58
|
+
spanStart = -1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function extractJsonWithSchema<T>(text: string, parse: (value: unknown) => T): T {
|
|
65
|
+
const value = extractJson(text);
|
|
66
|
+
if (value === null) throw new AIError('No JSON object or array found in model output', { kind: 'invalid_response' });
|
|
67
|
+
try {
|
|
68
|
+
return parse(value);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : 'JSON did not match expected schema';
|
|
71
|
+
throw new AIError(message, { kind: 'invalid_response', cause: error });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function requireString(record: Record<string, unknown>, key: string): string {
|
|
76
|
+
const value = record[key];
|
|
77
|
+
if (typeof value !== 'string') throw new Error(`Expected ${key} to be a string`);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function requireNumber(record: Record<string, unknown>, key: string): number {
|
|
82
|
+
const value = record[key];
|
|
83
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`Expected ${key} to be a number`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function requireStringArray(record: Record<string, unknown>, key: string): string[] {
|
|
88
|
+
const value = record[key];
|
|
89
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
90
|
+
throw new Error(`Expected ${key} to be a string array`);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function requireOptionalString(record: Record<string, unknown>, key: string): string | undefined {
|
|
96
|
+
const value = record[key];
|
|
97
|
+
if (value === undefined || value === null) return undefined;
|
|
98
|
+
if (typeof value !== 'string') throw new Error(`Expected ${key} to be a string when present`);
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tryParse(text: string): { ok: true; value: unknown } | { ok: false } {
|
|
103
|
+
try {
|
|
104
|
+
return { ok: true, value: JSON.parse(text) as unknown };
|
|
105
|
+
} catch {
|
|
106
|
+
return { ok: false };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { KeyRef, KeySource } from './types.js';
|
|
2
|
+
|
|
3
|
+
export function parseKeyRef(value?: string | null): KeyRef {
|
|
4
|
+
const trimmed = value?.trim();
|
|
5
|
+
if (!trimmed) return { kind: 'none' };
|
|
6
|
+
const braced = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed);
|
|
7
|
+
if (braced?.[1]) return { kind: 'env', name: braced[1] };
|
|
8
|
+
const dollar = /^\$([A-Z0-9_]+)$/.exec(trimmed);
|
|
9
|
+
if (dollar?.[1]) return { kind: 'env', name: dollar[1] };
|
|
10
|
+
if (/^[A-Z][A-Z0-9_]{2,}$/.test(trimmed)) return { kind: 'env', name: trimmed };
|
|
11
|
+
if (trimmed.startsWith('secret://')) return { kind: 'brokered', ref: trimmed };
|
|
12
|
+
if (trimmed.startsWith('stored://')) return { kind: 'stored', ref: trimmed.slice('stored://'.length) };
|
|
13
|
+
return { kind: 'literal', value: trimmed };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function envKeySource(env: Record<string, string | undefined> = globalEnv()): KeySource {
|
|
17
|
+
return {
|
|
18
|
+
async resolve(ref) {
|
|
19
|
+
if (ref.kind === 'none') return { ok: true, apiKey: null };
|
|
20
|
+
if (ref.kind !== 'env') return { ok: false, reason: 'denied' };
|
|
21
|
+
const apiKey = env[ref.name]?.trim();
|
|
22
|
+
return apiKey ? { ok: true, apiKey } : { ok: false, reason: 'missing' };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function literalKeySource(): KeySource {
|
|
28
|
+
return {
|
|
29
|
+
async resolve(ref) {
|
|
30
|
+
if (ref.kind === 'none') return { ok: true, apiKey: null };
|
|
31
|
+
if (ref.kind === 'literal') return { ok: true, apiKey: ref.value };
|
|
32
|
+
return { ok: false, reason: 'denied' };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function memoryKeySource(values: Record<string, string | null | undefined>): KeySource {
|
|
38
|
+
return {
|
|
39
|
+
async resolve(ref) {
|
|
40
|
+
if (ref.kind === 'none') return { ok: true, apiKey: null };
|
|
41
|
+
if (ref.kind === 'literal') return { ok: true, apiKey: ref.value };
|
|
42
|
+
const key = ref.kind === 'env' ? ref.name : ref.ref;
|
|
43
|
+
const value = values[key]?.trim();
|
|
44
|
+
return value ? { ok: true, apiKey: value } : { ok: false, reason: 'missing' };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function chainKeySources(...sources: KeySource[]): KeySource {
|
|
50
|
+
return {
|
|
51
|
+
async resolve(ref) {
|
|
52
|
+
let missing = false;
|
|
53
|
+
for (const source of sources) {
|
|
54
|
+
const result = await source.resolve(ref);
|
|
55
|
+
if (result.ok) return result;
|
|
56
|
+
if (result.reason === 'locked') return result;
|
|
57
|
+
if (result.reason === 'missing') {
|
|
58
|
+
missing = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return missing ? { ok: false, reason: 'missing' } : { ok: false, reason: 'denied' };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function globalEnv(): Record<string, string | undefined> {
|
|
68
|
+
const maybeProcess = globalThis as typeof globalThis & { process?: { env?: Record<string, string | undefined> } };
|
|
69
|
+
return maybeProcess.process?.env ?? {};
|
|
70
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GovernancePolicy } from './types.js';
|
|
2
|
+
|
|
3
|
+
export function allowAllPolicy(): GovernancePolicy {
|
|
4
|
+
return { checkModel: () => ({ allowed: true }) };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function blocklistPolicy(patterns: RegExp[]): GovernancePolicy {
|
|
8
|
+
return {
|
|
9
|
+
checkModel(provider, model) {
|
|
10
|
+
const target = `${provider}/${model}`;
|
|
11
|
+
const blocked = patterns.find((pattern) => pattern.test(target));
|
|
12
|
+
return blocked ? { allowed: false, reason: `Model blocked by policy: ${blocked}` } : { allowed: true };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function allowlistPolicy(prefixesByProvider: Record<string, string[]>): GovernancePolicy {
|
|
18
|
+
return {
|
|
19
|
+
checkModel(provider, model) {
|
|
20
|
+
const prefixes = prefixesByProvider[provider];
|
|
21
|
+
if (!prefixes || prefixes.length === 0) return { allowed: false, reason: `No models are allowed for provider ${provider}` };
|
|
22
|
+
return prefixes.some((prefix) => prefix === '*' || model.startsWith(prefix))
|
|
23
|
+
? { allowed: true }
|
|
24
|
+
: { allowed: false, reason: `Model is not allowed for provider ${provider}` };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function composePolicies(...policies: GovernancePolicy[]): GovernancePolicy {
|
|
30
|
+
return {
|
|
31
|
+
checkModel(provider, model) {
|
|
32
|
+
for (const policy of policies) {
|
|
33
|
+
const result = policy.checkModel(provider, model);
|
|
34
|
+
if (!result.allowed) return result;
|
|
35
|
+
}
|
|
36
|
+
return { allowed: true };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Redactor } from './types.js';
|
|
2
|
+
|
|
3
|
+
const SECRET_PATTERNS: RegExp[] = [
|
|
4
|
+
/sk-[A-Za-z0-9_-]{20,}/g,
|
|
5
|
+
/sk-proj-[A-Za-z0-9_-]{20,}/g,
|
|
6
|
+
/sk-ant-[A-Za-z0-9_-]{20,}/g,
|
|
7
|
+
/AIza[0-9A-Za-z_-]{20,}/g,
|
|
8
|
+
/gsk_[0-9A-Za-z_-]{20,}/g,
|
|
9
|
+
/xai-[0-9A-Za-z_-]{20,}/g,
|
|
10
|
+
/hf_[0-9A-Za-z]{20,}/g,
|
|
11
|
+
/nvapi-[0-9A-Za-z_-]{20,}/g,
|
|
12
|
+
/ghp_[0-9A-Za-z_]{20,}/g,
|
|
13
|
+
/github_pat_[0-9A-Za-z_]{20,}/g,
|
|
14
|
+
/glpat-[0-9A-Za-z_-]{20,}/g,
|
|
15
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi,
|
|
16
|
+
/xox[baprs]-[0-9A-Za-z-]{10,}/g,
|
|
17
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
18
|
+
/ASIA[0-9A-Z]{16}/g,
|
|
19
|
+
/SG\.[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}/g,
|
|
20
|
+
/rk_live_[0-9A-Za-z]{20,}/g,
|
|
21
|
+
/pk_live_[0-9A-Za-z]{20,}/g,
|
|
22
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
23
|
+
/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function createDefaultRedactor(extraSecrets: Iterable<string> = []): Redactor {
|
|
27
|
+
const secrets = new Set([...extraSecrets].filter((value) => value.length >= 6));
|
|
28
|
+
return {
|
|
29
|
+
redact(text: string): string {
|
|
30
|
+
let output = text;
|
|
31
|
+
for (const pattern of SECRET_PATTERNS) output = output.replace(pattern, '[REDACTED]');
|
|
32
|
+
for (const secret of secrets) output = output.split(secret).join('[REDACTED]');
|
|
33
|
+
return output;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class SessionRedactor implements Redactor {
|
|
39
|
+
private secrets = new Set<string>();
|
|
40
|
+
|
|
41
|
+
addSecret(value: string | null | undefined): void {
|
|
42
|
+
if (value && value.length >= 6) this.secrets.add(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
redact(text: string): string {
|
|
46
|
+
return createDefaultRedactor(this.secrets).redact(text);
|
|
47
|
+
}
|
|
48
|
+
}
|