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