@jxburros/ai-nugget 0.3.1 → 0.6.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/README.md +134 -271
- package/UPGRADING.md +158 -0
- package/dist/adapters/engines/anthropic.d.ts +7 -1
- package/dist/adapters/engines/anthropic.js +64 -37
- package/dist/adapters/engines/base.d.ts +29 -2
- package/dist/adapters/engines/base.js +52 -8
- package/dist/adapters/engines/google.d.ts +17 -1
- package/dist/adapters/engines/google.js +93 -29
- package/dist/adapters/engines/ollama.d.ts +2 -1
- package/dist/adapters/engines/ollama.js +51 -15
- package/dist/adapters/engines/openaiChat.d.ts +2 -1
- package/dist/adapters/engines/openaiChat.js +60 -30
- package/dist/adapters/profiles.d.ts +21 -1
- package/dist/adapters/profiles.js +12 -2
- package/dist/agent/loop.d.ts +43 -1
- package/dist/agent/loop.js +120 -13
- package/dist/cjs/adapters/engines/anthropic.js +308 -0
- package/dist/cjs/adapters/engines/base.js +147 -0
- package/dist/cjs/adapters/engines/google.js +284 -0
- package/dist/cjs/adapters/engines/ollama.js +199 -0
- package/dist/cjs/adapters/engines/openaiChat.js +261 -0
- package/dist/cjs/adapters/index.js +22 -0
- package/dist/cjs/adapters/profiles.js +125 -0
- package/dist/cjs/agent/index.js +18 -0
- package/dist/cjs/agent/loop.js +372 -0
- package/dist/cjs/agent/tools.js +51 -0
- package/dist/cjs/connect.js +27 -0
- package/dist/cjs/errors.js +110 -0
- package/dist/cjs/handler.js +561 -0
- package/dist/cjs/index.js +27 -0
- package/dist/cjs/json.js +121 -0
- package/dist/cjs/keys.js +81 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/policy.js +42 -0
- package/dist/cjs/redact.js +78 -0
- package/dist/cjs/tokens.js +22 -0
- package/dist/cjs/transport.js +172 -0
- package/dist/cjs/types.js +6 -0
- package/dist/cjs/util.js +112 -0
- package/dist/connect.d.ts +31 -0
- package/dist/connect.js +25 -0
- package/dist/errors.js +25 -1
- package/dist/handler.d.ts +61 -1
- package/dist/handler.js +247 -61
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/keys.js +1 -4
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +29 -1
- package/dist/transport.d.ts +3 -1
- package/dist/transport.js +21 -1
- package/dist/types.d.ts +96 -2
- package/dist/util.d.ts +16 -0
- package/dist/util.js +50 -0
- package/nugget/VERSION.txt +2 -2
- package/nugget/dist/adapters/engines/anthropic.d.ts +14 -0
- package/nugget/dist/adapters/engines/anthropic.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/anthropic.js +305 -0
- package/nugget/dist/adapters/engines/anthropic.js.map +1 -0
- package/nugget/dist/adapters/engines/base.d.ts +50 -0
- package/nugget/dist/adapters/engines/base.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/base.js +137 -0
- package/nugget/dist/adapters/engines/base.js.map +1 -0
- package/nugget/dist/adapters/engines/google.d.ts +24 -0
- package/nugget/dist/adapters/engines/google.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/google.js +281 -0
- package/nugget/dist/adapters/engines/google.js.map +1 -0
- package/nugget/dist/adapters/engines/ollama.d.ts +11 -0
- package/nugget/dist/adapters/engines/ollama.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/ollama.js +196 -0
- package/nugget/dist/adapters/engines/ollama.js.map +1 -0
- package/nugget/dist/adapters/engines/openaiChat.d.ts +16 -0
- package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/openaiChat.js +258 -0
- package/nugget/dist/adapters/engines/openaiChat.js.map +1 -0
- package/nugget/dist/adapters/index.d.ts +5 -0
- package/nugget/dist/adapters/index.d.ts.map +1 -0
- package/nugget/dist/adapters/index.js +17 -0
- package/nugget/dist/adapters/index.js.map +1 -0
- package/nugget/dist/adapters/profiles.d.ts +62 -0
- package/nugget/dist/adapters/profiles.d.ts.map +1 -0
- package/nugget/dist/adapters/profiles.js +121 -0
- package/nugget/dist/adapters/profiles.js.map +1 -0
- package/nugget/dist/agent/index.d.ts +3 -0
- package/nugget/dist/agent/index.d.ts.map +1 -0
- package/nugget/dist/agent/index.js +3 -0
- package/nugget/dist/agent/index.js.map +1 -0
- package/nugget/dist/agent/loop.d.ts +112 -0
- package/nugget/dist/agent/loop.d.ts.map +1 -0
- package/nugget/dist/agent/loop.js +370 -0
- package/nugget/dist/agent/loop.js.map +1 -0
- package/nugget/dist/agent/tools.d.ts +29 -0
- package/nugget/dist/agent/tools.d.ts.map +1 -0
- package/nugget/dist/agent/tools.js +48 -0
- package/nugget/dist/agent/tools.js.map +1 -0
- package/nugget/dist/cjs/adapters/engines/anthropic.js +308 -0
- package/nugget/dist/cjs/adapters/engines/base.js +147 -0
- package/nugget/dist/cjs/adapters/engines/google.js +284 -0
- package/nugget/dist/cjs/adapters/engines/ollama.js +199 -0
- package/nugget/dist/cjs/adapters/engines/openaiChat.js +261 -0
- package/nugget/dist/cjs/adapters/index.js +22 -0
- package/nugget/dist/cjs/adapters/profiles.js +125 -0
- package/nugget/dist/cjs/agent/index.js +18 -0
- package/nugget/dist/cjs/agent/loop.js +372 -0
- package/nugget/dist/cjs/agent/tools.js +51 -0
- package/nugget/dist/cjs/connect.js +27 -0
- package/nugget/dist/cjs/errors.js +110 -0
- package/nugget/dist/cjs/handler.js +561 -0
- package/nugget/dist/cjs/index.js +27 -0
- package/nugget/dist/cjs/json.js +121 -0
- package/nugget/dist/cjs/keys.js +81 -0
- package/nugget/dist/cjs/package.json +3 -0
- package/nugget/dist/cjs/policy.js +42 -0
- package/nugget/dist/cjs/redact.js +78 -0
- package/nugget/dist/cjs/tokens.js +22 -0
- package/nugget/dist/cjs/transport.js +172 -0
- package/nugget/dist/cjs/types.js +6 -0
- package/nugget/dist/cjs/util.js +112 -0
- package/nugget/dist/connect.d.ts +31 -0
- package/nugget/dist/connect.d.ts.map +1 -0
- package/nugget/dist/connect.js +25 -0
- package/nugget/dist/connect.js.map +1 -0
- package/nugget/dist/errors.d.ts +22 -0
- package/nugget/dist/errors.d.ts.map +1 -0
- package/nugget/dist/errors.js +104 -0
- package/nugget/dist/errors.js.map +1 -0
- package/nugget/dist/handler.d.ts +108 -0
- package/nugget/dist/handler.d.ts.map +1 -0
- package/nugget/dist/handler.js +557 -0
- package/nugget/dist/handler.js.map +1 -0
- package/nugget/dist/index.d.ts +12 -0
- package/nugget/dist/index.d.ts.map +1 -0
- package/nugget/dist/index.js +12 -0
- package/nugget/dist/index.js.map +1 -0
- package/nugget/dist/json.d.ts +7 -0
- package/nugget/dist/json.d.ts.map +1 -0
- package/nugget/dist/json.js +114 -0
- package/nugget/dist/json.js.map +1 -0
- package/nugget/dist/keys.d.ts +7 -0
- package/nugget/dist/keys.d.ts.map +1 -0
- package/nugget/dist/keys.js +75 -0
- package/nugget/dist/keys.js.map +1 -0
- package/nugget/dist/policy.d.ts +6 -0
- package/nugget/dist/policy.d.ts.map +1 -0
- package/nugget/dist/policy.js +37 -0
- package/nugget/dist/policy.js.map +1 -0
- package/nugget/dist/redact.d.ts +26 -0
- package/nugget/dist/redact.d.ts.map +1 -0
- package/nugget/dist/redact.js +74 -0
- package/nugget/dist/redact.js.map +1 -0
- package/nugget/dist/tokens.d.ts +5 -0
- package/nugget/dist/tokens.d.ts.map +1 -0
- package/nugget/dist/tokens.js +18 -0
- package/nugget/dist/tokens.js.map +1 -0
- package/nugget/dist/transport.d.ts +30 -0
- package/nugget/dist/transport.d.ts.map +1 -0
- package/nugget/dist/transport.js +165 -0
- package/nugget/dist/transport.js.map +1 -0
- package/nugget/dist/types.d.ts +285 -0
- package/nugget/dist/types.d.ts.map +1 -0
- package/nugget/dist/types.js +4 -0
- package/nugget/dist/types.js.map +1 -0
- package/nugget/dist/util.d.ts +24 -0
- package/nugget/dist/util.d.ts.map +1 -0
- package/nugget/dist/util.js +101 -0
- package/nugget/dist/util.js.map +1 -0
- package/nugget/src/adapters/engines/anthropic.ts +62 -37
- package/nugget/src/adapters/engines/base.ts +50 -7
- package/nugget/src/adapters/engines/google.ts +92 -29
- package/nugget/src/adapters/engines/ollama.ts +51 -17
- package/nugget/src/adapters/engines/openaiChat.ts +61 -32
- package/nugget/src/adapters/profiles.ts +53 -3
- package/nugget/src/agent/loop.ts +158 -14
- package/nugget/src/connect.ts +48 -0
- package/nugget/src/errors.ts +25 -1
- package/nugget/src/handler.ts +286 -59
- package/nugget/src/index.ts +1 -0
- package/nugget/src/keys.ts +1 -5
- package/nugget/src/redact.ts +32 -1
- package/nugget/src/transport.ts +20 -1
- package/nugget/src/types.ts +96 -1
- package/nugget/src/util.ts +55 -0
- package/package.json +20 -6
package/nugget/src/handler.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { adapterFor } from './adapters/index.js';
|
|
2
2
|
import { applyAuth, profileFor } from './adapters/profiles.js';
|
|
3
3
|
import { AIError, fromUnknown } from './errors.js';
|
|
4
|
+
import { extractJson } from './json.js';
|
|
4
5
|
import { allowAllPolicy } from './policy.js';
|
|
5
6
|
import { SessionRedactor } from './redact.js';
|
|
6
7
|
import type {
|
|
@@ -9,13 +10,18 @@ import type {
|
|
|
9
10
|
ChatRequest,
|
|
10
11
|
ChatResult,
|
|
11
12
|
Connection,
|
|
13
|
+
EmbedRequest,
|
|
14
|
+
EmbedResult,
|
|
12
15
|
GovernancePolicy,
|
|
13
16
|
KeySource,
|
|
14
17
|
ModelInfo,
|
|
18
|
+
ParsedResult,
|
|
15
19
|
Redactor,
|
|
16
20
|
ResolvedConnection,
|
|
21
|
+
StandardSchemaV1,
|
|
17
22
|
StreamEvent,
|
|
18
23
|
TelemetrySink,
|
|
24
|
+
Usage,
|
|
19
25
|
} from './types.js';
|
|
20
26
|
import { promptChars, sleep } from './util.js';
|
|
21
27
|
|
|
@@ -36,7 +42,39 @@ export interface HandlerOptions {
|
|
|
36
42
|
afterCall?(record: CallRecord): Promise<void>;
|
|
37
43
|
};
|
|
38
44
|
retry?: { maxAttempts?: number; baseDelayMs?: number; maxDelayMs?: number };
|
|
45
|
+
/**
|
|
46
|
+
* Concurrency and pacing limits, enforced by an **in-memory queue on this
|
|
47
|
+
* handler instance only**. There is no shared/external coordination, so N
|
|
48
|
+
* instances behind a load balancer allow N × `maxConcurrent` in flight against
|
|
49
|
+
* the provider. Size these per instance, or put a shared limiter in front if a
|
|
50
|
+
* global ceiling matters.
|
|
51
|
+
*/
|
|
39
52
|
limits?: { maxConcurrent?: number; minIntervalMs?: number };
|
|
53
|
+
/**
|
|
54
|
+
* Silence the one-time startup notice logged when no `policy` is supplied.
|
|
55
|
+
* Set this only when running unrestricted is a deliberate choice — passing an
|
|
56
|
+
* explicit `policy: allowAllPolicy()` says the same thing and is clearer.
|
|
57
|
+
*/
|
|
58
|
+
silencePolicyWarning?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Optional cost estimator. Called once per successful call/embedding with the
|
|
61
|
+
* final usage; its return value (USD) is stored on the telemetry record's
|
|
62
|
+
* `costUsd`. Return undefined to leave a call uncosted. Pricing tables live in
|
|
63
|
+
* the app, not the library — this is only the seam to attach one.
|
|
64
|
+
*/
|
|
65
|
+
pricing?(info: { provider: string; model: string; usage: Usage }): number | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A *startup* notice, so it fires once per process rather than once per handler
|
|
70
|
+
* — an app that builds a handler per request would otherwise flood its logs
|
|
71
|
+
* with a message that says the same thing every time.
|
|
72
|
+
*/
|
|
73
|
+
let warnedNoPolicy = false;
|
|
74
|
+
|
|
75
|
+
/** Test seam: reset the once-per-process startup notice. */
|
|
76
|
+
export function resetPolicyWarningForTests(): void {
|
|
77
|
+
warnedNoPolicy = false;
|
|
40
78
|
}
|
|
41
79
|
|
|
42
80
|
export class AIHandler {
|
|
@@ -48,6 +86,17 @@ export class AIHandler {
|
|
|
48
86
|
|
|
49
87
|
constructor(private opts: HandlerOptions) {
|
|
50
88
|
this.policy = opts.policy ?? allowAllPolicy();
|
|
89
|
+
// `allowAllPolicy()` stays the default — a library must not decide an app's
|
|
90
|
+
// governance — but "no policy configured" should be visible at runtime, not
|
|
91
|
+
// only in a README paragraph. Passing an explicit policy (including
|
|
92
|
+
// `allowAllPolicy()`) is the opt-out.
|
|
93
|
+
if (!opts.policy && !opts.silencePolicyWarning && !warnedNoPolicy) {
|
|
94
|
+
warnedNoPolicy = true;
|
|
95
|
+
globalThis.console?.warn?.(
|
|
96
|
+
'[ai-nugget] No GovernancePolicy configured — every provider/model is allowed. ' +
|
|
97
|
+
'Pass `policy` (e.g. allowlistPolicy(...)), or `policy: allowAllPolicy()` / `silencePolicyWarning: true` to accept this deliberately.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
51
100
|
}
|
|
52
101
|
|
|
53
102
|
async chat(conn: Connection, req: ChatRequest): Promise<ChatResult> {
|
|
@@ -64,41 +113,13 @@ export class AIHandler {
|
|
|
64
113
|
const callId = createCallId();
|
|
65
114
|
const startedAt = Date.now();
|
|
66
115
|
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
116
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
}
|
|
117
|
+
const preflight = await this.preflight(callId, conn, req.model, req.metadata);
|
|
118
|
+
if (!preflight.ok) return yield* this.failStream(callId, conn, req, startedAt, 1, preflight.error);
|
|
119
|
+
// One idempotency key per logical call, reused across every retry attempt,
|
|
120
|
+
// so a provider that supports it collapses a re-sent generation instead of
|
|
121
|
+
// billing it twice (see `supportsIdempotencyKey` in the profile table).
|
|
122
|
+
const resolved = withIdempotencyKey(preflight.resolved, callId);
|
|
102
123
|
|
|
103
124
|
let acquired = false;
|
|
104
125
|
let recorded = false;
|
|
@@ -132,31 +153,25 @@ export class AIHandler {
|
|
|
132
153
|
throw new AIError('Provider did not emit a done event', { kind: 'invalid_response', provider: conn.provider });
|
|
133
154
|
} catch (errorValue) {
|
|
134
155
|
const error = fromUnknown(errorValue, conn.provider);
|
|
156
|
+
// `emittedOutput` stops retries once any byte has reached the caller:
|
|
157
|
+
// never silently re-run a partially delivered answer.
|
|
135
158
|
if (!error.retryable || attempt >= maxAttempts || req.signal?.aborted || emittedOutput) {
|
|
136
|
-
await this.recordFailure(callId, conn, req, startedAt, attempt, error);
|
|
137
159
|
recorded = true;
|
|
138
|
-
yield
|
|
139
|
-
return;
|
|
160
|
+
return yield* this.failStream(callId, conn, req, startedAt, attempt, error);
|
|
140
161
|
}
|
|
141
162
|
const delayMs = this.retryDelay(error, attempt);
|
|
142
163
|
yield { type: 'retry', attempt, reason: error.kind, delayMs };
|
|
143
164
|
try {
|
|
144
165
|
await sleep(delayMs, req.signal);
|
|
145
166
|
} catch (sleepError) {
|
|
146
|
-
const canceled = fromUnknown(sleepError, conn.provider);
|
|
147
|
-
await this.recordFailure(callId, conn, req, startedAt, attempt, canceled);
|
|
148
167
|
recorded = true;
|
|
149
|
-
yield
|
|
150
|
-
return;
|
|
168
|
+
return yield* this.failStream(callId, conn, req, startedAt, attempt, fromUnknown(sleepError, conn.provider));
|
|
151
169
|
}
|
|
152
170
|
}
|
|
153
171
|
}
|
|
154
172
|
} catch (errorValue) {
|
|
155
|
-
const error = fromUnknown(errorValue, conn.provider);
|
|
156
|
-
await this.recordFailure(callId, conn, req, startedAt, Math.max(1, attempt), error);
|
|
157
173
|
recorded = true;
|
|
158
|
-
yield
|
|
159
|
-
return;
|
|
174
|
+
return yield* this.failStream(callId, conn, req, startedAt, Math.max(1, attempt), fromUnknown(errorValue, conn.provider));
|
|
160
175
|
} finally {
|
|
161
176
|
if (acquired) this.release();
|
|
162
177
|
if (acquired && !recorded) {
|
|
@@ -172,6 +187,59 @@ export class AIHandler {
|
|
|
172
187
|
}
|
|
173
188
|
}
|
|
174
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The shared pre-call preamble: policy check → key resolution → `beforeCall`
|
|
192
|
+
* hook. Every entry point (`stream`, `embed`, `listModels`/`testConnection`)
|
|
193
|
+
* runs exactly this sequence, so it lives once. Failures are *returned*, not
|
|
194
|
+
* recorded — each caller owns its own telemetry shape (`recordFailure` for
|
|
195
|
+
* chat/probe, `recordEmbed` for embeddings) and records the returned error.
|
|
196
|
+
*/
|
|
197
|
+
private async preflight(
|
|
198
|
+
callId: string,
|
|
199
|
+
conn: Connection,
|
|
200
|
+
model: string,
|
|
201
|
+
metadata: Record<string, unknown> | undefined,
|
|
202
|
+
): Promise<{ ok: true; resolved: ResolvedConnection } | { ok: false; error: AIError }> {
|
|
203
|
+
const policyResult = this.policy.checkModel(conn.provider, model);
|
|
204
|
+
if (!policyResult.allowed) {
|
|
205
|
+
return { ok: false, error: new AIError(policyResult.reason, { kind: 'policy_blocked', retryable: false, provider: conn.provider }) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let resolved: ResolvedConnection;
|
|
209
|
+
try {
|
|
210
|
+
resolved = await this.resolveConnection(conn);
|
|
211
|
+
} catch (errorValue) {
|
|
212
|
+
// Key resolution failures (missing/locked/denied) are a recorded outcome,
|
|
213
|
+
// not an uncaught throw — the traceability contract covers them too.
|
|
214
|
+
return { ok: false, error: fromUnknown(errorValue, conn.provider) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const info: CallInfo = {
|
|
219
|
+
callId,
|
|
220
|
+
connection: safeConnection(conn),
|
|
221
|
+
provider: conn.provider,
|
|
222
|
+
model,
|
|
223
|
+
metadata,
|
|
224
|
+
resolved: safeResolved(resolved),
|
|
225
|
+
};
|
|
226
|
+
if (await this.opts.hooks?.beforeCall?.(info) === 'deny') {
|
|
227
|
+
return { ok: false, error: new AIError('Call denied by beforeCall hook', { kind: 'policy_blocked', retryable: false, provider: conn.provider }) };
|
|
228
|
+
}
|
|
229
|
+
} catch (errorValue) {
|
|
230
|
+
// A throwing beforeCall hook is a recorded, redacted outcome too — not
|
|
231
|
+
// an uncaught throw that skips telemetry and the redaction guarantee.
|
|
232
|
+
return { ok: false, error: fromUnknown(errorValue, conn.provider) };
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, resolved };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Record a failed call and emit the single redacted `error` event for it. */
|
|
238
|
+
private async *failStream(callId: string, conn: Connection, req: ChatRequest, startedAt: number, attempts: number, error: AIError): AsyncGenerator<StreamEvent> {
|
|
239
|
+
await this.recordFailure(callId, conn, req, startedAt, attempts, error);
|
|
240
|
+
yield { type: 'error', error: this.redactedError(error) };
|
|
241
|
+
}
|
|
242
|
+
|
|
175
243
|
async listModels(conn: Connection): Promise<ModelInfo[]> {
|
|
176
244
|
return this.runProbe(conn, '__listModels__', async (resolved) => {
|
|
177
245
|
const adapter = adapterFor(conn.provider, conn.baseUrl);
|
|
@@ -196,23 +264,112 @@ export class AIHandler {
|
|
|
196
264
|
}
|
|
197
265
|
}
|
|
198
266
|
|
|
199
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Warm a connection so the first real call doesn't pay cold DNS/TLS and
|
|
269
|
+
* (for a local daemon) process spin-up on the hot path. Runs a lightweight
|
|
270
|
+
* health probe and never throws — call it once at boot. Combined with a CJS
|
|
271
|
+
* `require` path it removes the cold-start "offline" false negative.
|
|
272
|
+
*/
|
|
273
|
+
async prewarm(conn: Connection): Promise<void> {
|
|
274
|
+
try {
|
|
275
|
+
await this.testConnection(conn);
|
|
276
|
+
} catch {
|
|
277
|
+
// Warm-up only: a failure here just means the first real call pays the cost.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Chat that returns typed, validated output. Requests JSON mode, extracts the
|
|
283
|
+
* JSON from the reply, and validates it against any Standard Schema validator
|
|
284
|
+
* (Zod / Valibot / ArkType). On a validation miss it performs exactly one
|
|
285
|
+
* corrective retry that shows the model its error, then throws
|
|
286
|
+
* `invalid_response` if still invalid. No schema library is bundled — the
|
|
287
|
+
* caller supplies the schema.
|
|
288
|
+
*/
|
|
289
|
+
async chatParsed<T>(conn: Connection, req: ChatRequest, schema: StandardSchemaV1<T>): Promise<ParsedResult<T>> {
|
|
290
|
+
const base: ChatRequest = { ...req, responseFormat: req.responseFormat ?? { type: 'json' } };
|
|
291
|
+
let result = await this.chat(conn, base);
|
|
292
|
+
let validated = await validateWithSchema(schema, result.text);
|
|
293
|
+
if (validated.ok) return { data: validated.value, result };
|
|
294
|
+
|
|
295
|
+
const retry: ChatRequest = {
|
|
296
|
+
...base,
|
|
297
|
+
messages: [
|
|
298
|
+
...base.messages,
|
|
299
|
+
{ role: 'assistant', content: result.text },
|
|
300
|
+
{ role: 'user', content: `That response did not match the required schema (${validated.message}). Reply again with ONLY a valid JSON value that matches it.` },
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
result = await this.chat(conn, retry);
|
|
304
|
+
validated = await validateWithSchema(schema, result.text);
|
|
305
|
+
if (validated.ok) return { data: validated.value, result };
|
|
306
|
+
throw new AIError(`Model output failed schema validation: ${validated.message}`, { kind: 'invalid_response', provider: conn.provider });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Produce embeddings through the same governed pipeline as chat: policy check,
|
|
311
|
+
* key resolution, `beforeCall` hook, concurrency, and exactly one redacted
|
|
312
|
+
* telemetry record. Throws a typed `invalid_request` error for providers whose
|
|
313
|
+
* adapter has no `embed` (Anthropic, Google) rather than failing silently.
|
|
314
|
+
*/
|
|
315
|
+
async embed(conn: Connection, req: EmbedRequest): Promise<EmbedResult> {
|
|
200
316
|
const callId = createCallId();
|
|
201
317
|
const startedAt = Date.now();
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
|
|
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);
|
|
318
|
+
const inputChars = (Array.isArray(req.input) ? req.input : [req.input]).reduce((sum, text) => sum + text.length, 0);
|
|
319
|
+
const fail = async (error: AIError): Promise<never> => {
|
|
320
|
+
await this.recordEmbed(callId, conn, req, startedAt, { estimated: true }, inputChars, error);
|
|
207
321
|
throw this.redactedError(error);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const preflight = await this.preflight(callId, conn, req.model, req.metadata);
|
|
325
|
+
if (!preflight.ok) return fail(preflight.error);
|
|
326
|
+
const resolved = preflight.resolved;
|
|
327
|
+
|
|
328
|
+
const adapter = adapterFor(conn.provider, conn.baseUrl);
|
|
329
|
+
if (!adapter.embed) {
|
|
330
|
+
return fail(new AIError(`Provider ${conn.provider} does not support embeddings`, { kind: 'invalid_request', retryable: false, provider: conn.provider }));
|
|
208
331
|
}
|
|
209
|
-
|
|
332
|
+
|
|
333
|
+
await this.acquire(req.signal);
|
|
210
334
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
335
|
+
const result = await adapter.embed(resolved, req);
|
|
336
|
+
await this.recordEmbed(callId, conn, req, startedAt, result.usage, inputChars);
|
|
337
|
+
return result;
|
|
338
|
+
} catch (errorValue) {
|
|
339
|
+
return fail(fromUnknown(errorValue, conn.provider));
|
|
340
|
+
} finally {
|
|
341
|
+
this.release();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async recordEmbed(callId: string, conn: Connection, req: EmbedRequest, startedAt: number, usage: Usage, inputChars: number, error?: AIError): Promise<void> {
|
|
346
|
+
const costUsd = error ? undefined : this.opts.pricing?.({ provider: conn.provider, model: req.model, usage });
|
|
347
|
+
await this.record({
|
|
348
|
+
callId,
|
|
349
|
+
connectionId: conn.id,
|
|
350
|
+
provider: conn.provider,
|
|
351
|
+
model: req.model,
|
|
352
|
+
startedAt,
|
|
353
|
+
timing: { firstTokenMs: null, totalMs: Date.now() - startedAt },
|
|
354
|
+
usage,
|
|
355
|
+
finishReason: error ? (error.kind === 'canceled' ? 'canceled' : 'error') : 'stop',
|
|
356
|
+
...(error ? { error: { kind: error.kind, status: error.status, message: error.message } } : {}),
|
|
357
|
+
attempts: 1,
|
|
358
|
+
metadata: { ...req.metadata, operation: '__embed__' },
|
|
359
|
+
promptChars: inputChars,
|
|
360
|
+
responseChars: 0,
|
|
361
|
+
...(costUsd !== undefined ? { costUsd } : {}),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async runProbe<T>(conn: Connection, operation: '__listModels__' | '__testConnection__', action: (resolved: ResolvedConnection) => Promise<T>): Promise<T> {
|
|
366
|
+
const callId = createCallId();
|
|
367
|
+
const startedAt = Date.now();
|
|
368
|
+
const req: ChatRequest = { model: operation, messages: [], metadata: { operation } };
|
|
369
|
+
try {
|
|
370
|
+
const preflight = await this.preflight(callId, conn, operation, req.metadata);
|
|
371
|
+
if (!preflight.ok) throw preflight.error;
|
|
372
|
+
const resolved = preflight.resolved;
|
|
216
373
|
await this.acquire(undefined);
|
|
217
374
|
try {
|
|
218
375
|
const value = await action(resolved);
|
|
@@ -274,12 +431,17 @@ export class AIHandler {
|
|
|
274
431
|
}
|
|
275
432
|
this.active += 1;
|
|
276
433
|
const minInterval = this.opts.limits?.minIntervalMs ?? 0;
|
|
277
|
-
const
|
|
278
|
-
|
|
434
|
+
const previousLastStarted = this.lastStarted;
|
|
435
|
+
const wait = Math.max(0, previousLastStarted + minInterval - Date.now());
|
|
436
|
+
const reserved = Date.now() + wait;
|
|
437
|
+
this.lastStarted = reserved;
|
|
279
438
|
try {
|
|
280
439
|
if (wait) await sleep(wait, signal);
|
|
281
440
|
} catch (error) {
|
|
282
441
|
this.release();
|
|
442
|
+
// Only undo our own reservation — a later caller may have already
|
|
443
|
+
// paced itself off `reserved` and must keep that spacing.
|
|
444
|
+
if (this.lastStarted === reserved) this.lastStarted = previousLastStarted;
|
|
283
445
|
throw error;
|
|
284
446
|
}
|
|
285
447
|
}
|
|
@@ -304,6 +466,7 @@ export class AIHandler {
|
|
|
304
466
|
}
|
|
305
467
|
|
|
306
468
|
private async recordSuccess(callId: string, conn: Connection, req: ChatRequest, startedAt: number, attempts: number, result: ChatResult): Promise<void> {
|
|
469
|
+
const costUsd = this.opts.pricing?.({ provider: conn.provider, model: req.model, usage: result.usage });
|
|
307
470
|
await this.record({
|
|
308
471
|
callId,
|
|
309
472
|
connectionId: conn.id,
|
|
@@ -317,6 +480,7 @@ export class AIHandler {
|
|
|
317
480
|
metadata: req.metadata,
|
|
318
481
|
promptChars: promptChars(req.messages),
|
|
319
482
|
responseChars: result.text.length,
|
|
483
|
+
...(costUsd !== undefined ? { costUsd } : {}),
|
|
320
484
|
});
|
|
321
485
|
}
|
|
322
486
|
|
|
@@ -403,6 +567,69 @@ function trimSlash(value: string): string {
|
|
|
403
567
|
return value.replace(/\/+$/, '');
|
|
404
568
|
}
|
|
405
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Drops the resolved API key *and* neutralizes the auth headers built from it,
|
|
572
|
+
* so a `beforeCall` hook can inspect the endpoint but never the secret.
|
|
573
|
+
* `keyRef` is scrubbed too — a `{ kind: 'literal' }` ref carries the plaintext
|
|
574
|
+
* key, and hooks routinely log the whole object.
|
|
575
|
+
*/
|
|
576
|
+
function safeResolved(resolved: ResolvedConnection): Omit<ResolvedConnection, 'apiKey'> {
|
|
577
|
+
const { apiKey: _apiKey, headers, ...rest } = resolved;
|
|
578
|
+
return { ...rest, ...safeKeyRef(resolved), headers: maskAuthHeaders(headers) };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The caller's own `Connection`, with any plaintext `keyRef` masked. Hooks get
|
|
583
|
+
* the connection so they can key off `id`/`provider`/`baseUrl`; they must never
|
|
584
|
+
* be handed the secret, since logging `info.connection` for audit is the
|
|
585
|
+
* obvious thing to do and would otherwise ship the key in clear text.
|
|
586
|
+
*/
|
|
587
|
+
function safeConnection(conn: Connection): Connection {
|
|
588
|
+
return { ...conn, ...safeKeyRef(conn) };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function safeKeyRef(conn: Connection): { keyRef?: Connection['keyRef'] } {
|
|
592
|
+
if (conn.keyRef?.kind !== 'literal') return conn.keyRef === undefined ? {} : { keyRef: conn.keyRef };
|
|
593
|
+
return { keyRef: { kind: 'literal', value: REDACTED } };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const REDACTED = '[REDACTED]';
|
|
597
|
+
|
|
598
|
+
/** Header names whose value is derived from the API key by {@link applyAuth}. */
|
|
599
|
+
const AUTH_HEADERS = new Set(['authorization', 'x-api-key', 'x-goog-api-key', 'api-key']);
|
|
600
|
+
|
|
601
|
+
function maskAuthHeaders(headers: Record<string, string>): Record<string, string> {
|
|
602
|
+
const masked: Record<string, string> = {};
|
|
603
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
604
|
+
masked[name] = AUTH_HEADERS.has(name.toLowerCase()) ? REDACTED : value;
|
|
605
|
+
}
|
|
606
|
+
return masked;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Attach a per-call `Idempotency-Key` for providers that honor one, so a retry
|
|
611
|
+
* after a dropped connection can't be billed as a second generation. Only set
|
|
612
|
+
* where the profile declares support, and never overrides a caller-supplied
|
|
613
|
+
* header.
|
|
614
|
+
*/
|
|
615
|
+
function withIdempotencyKey(resolved: ResolvedConnection, callId: string): ResolvedConnection {
|
|
616
|
+
const profile = profileFor(resolved.provider, resolved.baseUrl);
|
|
617
|
+
if (!profile.quirks?.supportsIdempotencyKey) return resolved;
|
|
618
|
+
if (Object.keys(resolved.headers).some((name) => name.toLowerCase() === 'idempotency-key')) return resolved;
|
|
619
|
+
return { ...resolved, headers: { ...resolved.headers, 'idempotency-key': callId } };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function validateWithSchema<T>(schema: StandardSchemaV1<T>, text: string): Promise<{ ok: true; value: T } | { ok: false; message: string }> {
|
|
623
|
+
const value = extractJson(text);
|
|
624
|
+
if (value === null) return { ok: false, message: 'no JSON found in model output' };
|
|
625
|
+
const result = await schema['~standard'].validate(value);
|
|
626
|
+
if (result.issues) {
|
|
627
|
+
const message = result.issues.map((issue) => issue.message).join('; ');
|
|
628
|
+
return { ok: false, message: message || 'validation failed' };
|
|
629
|
+
}
|
|
630
|
+
return { ok: true, value: result.value };
|
|
631
|
+
}
|
|
632
|
+
|
|
406
633
|
function createCallId(): string {
|
|
407
634
|
return globalThis.crypto?.randomUUID?.() ?? `call_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
408
635
|
}
|
package/nugget/src/index.ts
CHANGED
package/nugget/src/keys.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { KeyRef, KeySource } from './types.js';
|
|
2
|
+
import { globalEnv } from './util.js';
|
|
2
3
|
|
|
3
4
|
export function parseKeyRef(value?: string | null): KeyRef {
|
|
4
5
|
const trimmed = value?.trim();
|
|
@@ -63,8 +64,3 @@ export function chainKeySources(...sources: KeySource[]): KeySource {
|
|
|
63
64
|
},
|
|
64
65
|
};
|
|
65
66
|
}
|
|
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
|
-
}
|
package/nugget/src/redact.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Redactor } from './types.js';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Prefixed secret formats. Each pattern is anchored on a vendor prefix that is
|
|
5
|
+
* distinctive enough that a match is a secret, not a coincidence.
|
|
6
|
+
*/
|
|
7
|
+
export const SECRET_PATTERNS: RegExp[] = [
|
|
4
8
|
/sk-[A-Za-z0-9_-]{20,}/g,
|
|
5
9
|
/sk-proj-[A-Za-z0-9_-]{20,}/g,
|
|
6
10
|
/sk-ant-[A-Za-z0-9_-]{20,}/g,
|
|
@@ -23,12 +27,39 @@ const SECRET_PATTERNS: RegExp[] = [
|
|
|
23
27
|
/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
|
|
24
28
|
];
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Unprefixed/opaque secrets — Azure OpenAI `api-key` values, AWS *secret*
|
|
32
|
+
* access keys, generic bearer/session tokens — carry no distinctive prefix, so
|
|
33
|
+
* matching them on shape alone would redact commit SHAs, content hashes, and
|
|
34
|
+
* base64 payloads. These patterns instead anchor on the *label* that precedes
|
|
35
|
+
* the value (`api-key: …`, `"aws_secret_access_key": "…"`, `password=…`) and
|
|
36
|
+
* redact only the value, keeping the label readable in logs.
|
|
37
|
+
*
|
|
38
|
+
* Exact-match registration (`SessionRedactor.addSecret`, which the handler does
|
|
39
|
+
* for every resolved key) remains the only guaranteed catch for a bare,
|
|
40
|
+
* unlabeled, unprefixed secret.
|
|
41
|
+
*/
|
|
42
|
+
export const LABELED_SECRET_PATTERNS: RegExp[] = [
|
|
43
|
+
new RegExp(
|
|
44
|
+
// Lookbehind rather than `\b` so an underscore-joined prefix still matches
|
|
45
|
+
// the label (`aws_secret_access_key`, `azure_api_key`).
|
|
46
|
+
String.raw`(?<![A-Za-z0-9])(api[_-]?key|apikey|secret[_-]?access[_-]?key|access[_-]?token|auth[_-]?token|session[_-]?token|client[_-]?secret|refresh[_-]?token|private[_-]?key|password|passwd)` +
|
|
47
|
+
// Optional closing quote handles JSON keys (`"api_key": "…"`).
|
|
48
|
+
String.raw`(["']?\s*[:=]\s*|\s+)` +
|
|
49
|
+
String.raw`(["']?)([A-Za-z0-9/+_.~-]{16,}={0,2})\3`,
|
|
50
|
+
'gi',
|
|
51
|
+
),
|
|
52
|
+
];
|
|
53
|
+
|
|
26
54
|
export function createDefaultRedactor(extraSecrets: Iterable<string> = []): Redactor {
|
|
27
55
|
const secrets = new Set([...extraSecrets].filter((value) => value.length >= 6));
|
|
28
56
|
return {
|
|
29
57
|
redact(text: string): string {
|
|
30
58
|
let output = text;
|
|
31
59
|
for (const pattern of SECRET_PATTERNS) output = output.replace(pattern, '[REDACTED]');
|
|
60
|
+
for (const pattern of LABELED_SECRET_PATTERNS) {
|
|
61
|
+
output = output.replace(pattern, (_match, label: string, separator: string, quote: string) => `${label}${separator}${quote}[REDACTED]${quote}`);
|
|
62
|
+
}
|
|
32
63
|
for (const secret of secrets) output = output.split(secret).join('[REDACTED]');
|
|
33
64
|
return output;
|
|
34
65
|
},
|
package/nugget/src/transport.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { AIError, classify, fromUnknown } from './errors.js';
|
|
2
2
|
|
|
3
|
-
export function withTimeout(ms: number, external?: AbortSignal): {
|
|
3
|
+
export function withTimeout(ms: number, external?: AbortSignal, idleMs?: number): {
|
|
4
4
|
signal: AbortSignal;
|
|
5
5
|
done(): void;
|
|
6
6
|
timedOut(): boolean;
|
|
7
|
+
/** Reset the idle timer. No-op unless `idleMs` was supplied. Call on each chunk. */
|
|
8
|
+
bump(): void;
|
|
7
9
|
} {
|
|
8
10
|
const controller = new AbortController();
|
|
9
11
|
let didTimeOut = false;
|
|
@@ -11,20 +13,37 @@ export function withTimeout(ms: number, external?: AbortSignal): {
|
|
|
11
13
|
didTimeOut = true;
|
|
12
14
|
controller.abort(new AIError(`Request timed out after ${ms}ms`, { kind: 'timeout' }));
|
|
13
15
|
}, ms);
|
|
16
|
+
// Optional idle timeout: aborts only when no chunk has arrived for `idleMs`,
|
|
17
|
+
// rearmed on every `bump()`. Keeps a long but healthy stream alive while the
|
|
18
|
+
// total `ms` ceiling still bounds a truly stuck call.
|
|
19
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
20
|
+
const armIdle = (): void => {
|
|
21
|
+
if (idleMs === undefined) return;
|
|
22
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
23
|
+
idleTimer = setTimeout(() => {
|
|
24
|
+
didTimeOut = true;
|
|
25
|
+
controller.abort(new AIError(`Request idle for ${idleMs}ms`, { kind: 'timeout' }));
|
|
26
|
+
}, idleMs);
|
|
27
|
+
};
|
|
14
28
|
const onAbort = () => controller.abort(external?.reason);
|
|
15
29
|
if (external) {
|
|
16
30
|
if (external.aborted) onAbort();
|
|
17
31
|
else external.addEventListener('abort', onAbort, { once: true });
|
|
18
32
|
}
|
|
33
|
+
armIdle();
|
|
19
34
|
return {
|
|
20
35
|
signal: controller.signal,
|
|
21
36
|
done() {
|
|
22
37
|
clearTimeout(timer);
|
|
38
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
23
39
|
external?.removeEventListener('abort', onAbort);
|
|
24
40
|
},
|
|
25
41
|
timedOut() {
|
|
26
42
|
return didTimeOut;
|
|
27
43
|
},
|
|
44
|
+
bump() {
|
|
45
|
+
armIdle();
|
|
46
|
+
},
|
|
28
47
|
};
|
|
29
48
|
}
|
|
30
49
|
|