@jxburros/ai-nugget 0.4.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.
Files changed (175) hide show
  1. package/README.md +132 -385
  2. package/UPGRADING.md +158 -0
  3. package/dist/adapters/engines/anthropic.d.ts +7 -1
  4. package/dist/adapters/engines/anthropic.js +64 -37
  5. package/dist/adapters/engines/base.d.ts +29 -2
  6. package/dist/adapters/engines/base.js +52 -8
  7. package/dist/adapters/engines/google.d.ts +7 -1
  8. package/dist/adapters/engines/google.js +73 -29
  9. package/dist/adapters/engines/ollama.d.ts +2 -1
  10. package/dist/adapters/engines/ollama.js +51 -15
  11. package/dist/adapters/engines/openaiChat.d.ts +2 -1
  12. package/dist/adapters/engines/openaiChat.js +60 -30
  13. package/dist/adapters/profiles.d.ts +21 -1
  14. package/dist/adapters/profiles.js +12 -2
  15. package/dist/agent/loop.d.ts +43 -1
  16. package/dist/agent/loop.js +116 -12
  17. package/dist/cjs/adapters/engines/anthropic.js +308 -0
  18. package/dist/cjs/adapters/engines/base.js +147 -0
  19. package/dist/cjs/adapters/engines/google.js +284 -0
  20. package/dist/cjs/adapters/engines/ollama.js +199 -0
  21. package/dist/cjs/adapters/engines/openaiChat.js +261 -0
  22. package/dist/cjs/adapters/index.js +22 -0
  23. package/dist/cjs/adapters/profiles.js +125 -0
  24. package/dist/cjs/agent/index.js +18 -0
  25. package/dist/cjs/agent/loop.js +372 -0
  26. package/dist/cjs/agent/tools.js +51 -0
  27. package/dist/cjs/connect.js +27 -0
  28. package/dist/cjs/errors.js +110 -0
  29. package/dist/cjs/handler.js +561 -0
  30. package/dist/cjs/index.js +27 -0
  31. package/dist/cjs/json.js +121 -0
  32. package/dist/cjs/keys.js +81 -0
  33. package/dist/cjs/package.json +3 -0
  34. package/dist/cjs/policy.js +42 -0
  35. package/dist/cjs/redact.js +78 -0
  36. package/dist/cjs/tokens.js +22 -0
  37. package/dist/cjs/transport.js +172 -0
  38. package/dist/cjs/types.js +6 -0
  39. package/dist/cjs/util.js +112 -0
  40. package/dist/errors.js +25 -1
  41. package/dist/handler.d.ts +61 -1
  42. package/dist/handler.js +239 -59
  43. package/dist/redact.d.ts +18 -0
  44. package/dist/redact.js +29 -1
  45. package/dist/transport.d.ts +3 -1
  46. package/dist/transport.js +21 -1
  47. package/dist/types.d.ts +96 -2
  48. package/dist/util.d.ts +15 -0
  49. package/dist/util.js +46 -0
  50. package/nugget/VERSION.txt +2 -2
  51. package/nugget/dist/adapters/engines/anthropic.d.ts +14 -0
  52. package/nugget/dist/adapters/engines/anthropic.d.ts.map +1 -0
  53. package/nugget/dist/adapters/engines/anthropic.js +305 -0
  54. package/nugget/dist/adapters/engines/anthropic.js.map +1 -0
  55. package/nugget/dist/adapters/engines/base.d.ts +50 -0
  56. package/nugget/dist/adapters/engines/base.d.ts.map +1 -0
  57. package/nugget/dist/adapters/engines/base.js +137 -0
  58. package/nugget/dist/adapters/engines/base.js.map +1 -0
  59. package/nugget/dist/adapters/engines/google.d.ts +24 -0
  60. package/nugget/dist/adapters/engines/google.d.ts.map +1 -0
  61. package/nugget/dist/adapters/engines/google.js +281 -0
  62. package/nugget/dist/adapters/engines/google.js.map +1 -0
  63. package/nugget/dist/adapters/engines/ollama.d.ts +11 -0
  64. package/nugget/dist/adapters/engines/ollama.d.ts.map +1 -0
  65. package/nugget/dist/adapters/engines/ollama.js +196 -0
  66. package/nugget/dist/adapters/engines/ollama.js.map +1 -0
  67. package/nugget/dist/adapters/engines/openaiChat.d.ts +16 -0
  68. package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -0
  69. package/nugget/dist/adapters/engines/openaiChat.js +258 -0
  70. package/nugget/dist/adapters/engines/openaiChat.js.map +1 -0
  71. package/nugget/dist/adapters/index.d.ts +5 -0
  72. package/nugget/dist/adapters/index.d.ts.map +1 -0
  73. package/nugget/dist/adapters/index.js +17 -0
  74. package/nugget/dist/adapters/index.js.map +1 -0
  75. package/nugget/dist/adapters/profiles.d.ts +62 -0
  76. package/nugget/dist/adapters/profiles.d.ts.map +1 -0
  77. package/nugget/dist/adapters/profiles.js +121 -0
  78. package/nugget/dist/adapters/profiles.js.map +1 -0
  79. package/nugget/dist/agent/index.d.ts +3 -0
  80. package/nugget/dist/agent/index.d.ts.map +1 -0
  81. package/nugget/dist/agent/index.js +3 -0
  82. package/nugget/dist/agent/index.js.map +1 -0
  83. package/nugget/dist/agent/loop.d.ts +112 -0
  84. package/nugget/dist/agent/loop.d.ts.map +1 -0
  85. package/nugget/dist/agent/loop.js +370 -0
  86. package/nugget/dist/agent/loop.js.map +1 -0
  87. package/nugget/dist/agent/tools.d.ts +29 -0
  88. package/nugget/dist/agent/tools.d.ts.map +1 -0
  89. package/nugget/dist/agent/tools.js +48 -0
  90. package/nugget/dist/agent/tools.js.map +1 -0
  91. package/nugget/dist/cjs/adapters/engines/anthropic.js +308 -0
  92. package/nugget/dist/cjs/adapters/engines/base.js +147 -0
  93. package/nugget/dist/cjs/adapters/engines/google.js +284 -0
  94. package/nugget/dist/cjs/adapters/engines/ollama.js +199 -0
  95. package/nugget/dist/cjs/adapters/engines/openaiChat.js +261 -0
  96. package/nugget/dist/cjs/adapters/index.js +22 -0
  97. package/nugget/dist/cjs/adapters/profiles.js +125 -0
  98. package/nugget/dist/cjs/agent/index.js +18 -0
  99. package/nugget/dist/cjs/agent/loop.js +372 -0
  100. package/nugget/dist/cjs/agent/tools.js +51 -0
  101. package/nugget/dist/cjs/connect.js +27 -0
  102. package/nugget/dist/cjs/errors.js +110 -0
  103. package/nugget/dist/cjs/handler.js +561 -0
  104. package/nugget/dist/cjs/index.js +27 -0
  105. package/nugget/dist/cjs/json.js +121 -0
  106. package/nugget/dist/cjs/keys.js +81 -0
  107. package/nugget/dist/cjs/package.json +3 -0
  108. package/nugget/dist/cjs/policy.js +42 -0
  109. package/nugget/dist/cjs/redact.js +78 -0
  110. package/nugget/dist/cjs/tokens.js +22 -0
  111. package/nugget/dist/cjs/transport.js +172 -0
  112. package/nugget/dist/cjs/types.js +6 -0
  113. package/nugget/dist/cjs/util.js +112 -0
  114. package/nugget/dist/connect.d.ts +31 -0
  115. package/nugget/dist/connect.d.ts.map +1 -0
  116. package/nugget/dist/connect.js +25 -0
  117. package/nugget/dist/connect.js.map +1 -0
  118. package/nugget/dist/errors.d.ts +22 -0
  119. package/nugget/dist/errors.d.ts.map +1 -0
  120. package/nugget/dist/errors.js +104 -0
  121. package/nugget/dist/errors.js.map +1 -0
  122. package/nugget/dist/handler.d.ts +108 -0
  123. package/nugget/dist/handler.d.ts.map +1 -0
  124. package/nugget/dist/handler.js +557 -0
  125. package/nugget/dist/handler.js.map +1 -0
  126. package/nugget/dist/index.d.ts +12 -0
  127. package/nugget/dist/index.d.ts.map +1 -0
  128. package/nugget/dist/index.js +12 -0
  129. package/nugget/dist/index.js.map +1 -0
  130. package/nugget/dist/json.d.ts +7 -0
  131. package/nugget/dist/json.d.ts.map +1 -0
  132. package/nugget/dist/json.js +114 -0
  133. package/nugget/dist/json.js.map +1 -0
  134. package/nugget/dist/keys.d.ts +7 -0
  135. package/nugget/dist/keys.d.ts.map +1 -0
  136. package/nugget/dist/keys.js +75 -0
  137. package/nugget/dist/keys.js.map +1 -0
  138. package/nugget/dist/policy.d.ts +6 -0
  139. package/nugget/dist/policy.d.ts.map +1 -0
  140. package/nugget/dist/policy.js +37 -0
  141. package/nugget/dist/policy.js.map +1 -0
  142. package/nugget/dist/redact.d.ts +26 -0
  143. package/nugget/dist/redact.d.ts.map +1 -0
  144. package/nugget/dist/redact.js +74 -0
  145. package/nugget/dist/redact.js.map +1 -0
  146. package/nugget/dist/tokens.d.ts +5 -0
  147. package/nugget/dist/tokens.d.ts.map +1 -0
  148. package/nugget/dist/tokens.js +18 -0
  149. package/nugget/dist/tokens.js.map +1 -0
  150. package/nugget/dist/transport.d.ts +30 -0
  151. package/nugget/dist/transport.d.ts.map +1 -0
  152. package/nugget/dist/transport.js +165 -0
  153. package/nugget/dist/transport.js.map +1 -0
  154. package/nugget/dist/types.d.ts +285 -0
  155. package/nugget/dist/types.d.ts.map +1 -0
  156. package/nugget/dist/types.js +4 -0
  157. package/nugget/dist/types.js.map +1 -0
  158. package/nugget/dist/util.d.ts +24 -0
  159. package/nugget/dist/util.d.ts.map +1 -0
  160. package/nugget/dist/util.js +101 -0
  161. package/nugget/dist/util.js.map +1 -0
  162. package/nugget/src/adapters/engines/anthropic.ts +62 -37
  163. package/nugget/src/adapters/engines/base.ts +50 -7
  164. package/nugget/src/adapters/engines/google.ts +72 -29
  165. package/nugget/src/adapters/engines/ollama.ts +51 -17
  166. package/nugget/src/adapters/engines/openaiChat.ts +61 -32
  167. package/nugget/src/adapters/profiles.ts +53 -3
  168. package/nugget/src/agent/loop.ts +153 -13
  169. package/nugget/src/errors.ts +25 -1
  170. package/nugget/src/handler.ts +279 -57
  171. package/nugget/src/redact.ts +32 -1
  172. package/nugget/src/transport.ts +20 -1
  173. package/nugget/src/types.ts +96 -1
  174. package/nugget/src/util.ts +50 -0
  175. package/package.json +18 -5
package/dist/handler.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CallInfo, CallRecord, ChatRequest, ChatResult, Connection, GovernancePolicy, KeySource, ModelInfo, Redactor, StreamEvent, TelemetrySink } from './types.js';
1
+ import type { CallInfo, CallRecord, ChatRequest, ChatResult, Connection, EmbedRequest, EmbedResult, GovernancePolicy, KeySource, ModelInfo, ParsedResult, Redactor, StandardSchemaV1, StreamEvent, TelemetrySink, Usage } from './types.js';
2
2
  export interface HandlerOptions {
3
3
  keySource: KeySource;
4
4
  policy?: GovernancePolicy;
@@ -13,11 +13,37 @@ export interface HandlerOptions {
13
13
  baseDelayMs?: number;
14
14
  maxDelayMs?: number;
15
15
  };
16
+ /**
17
+ * Concurrency and pacing limits, enforced by an **in-memory queue on this
18
+ * handler instance only**. There is no shared/external coordination, so N
19
+ * instances behind a load balancer allow N × `maxConcurrent` in flight against
20
+ * the provider. Size these per instance, or put a shared limiter in front if a
21
+ * global ceiling matters.
22
+ */
16
23
  limits?: {
17
24
  maxConcurrent?: number;
18
25
  minIntervalMs?: number;
19
26
  };
27
+ /**
28
+ * Silence the one-time startup notice logged when no `policy` is supplied.
29
+ * Set this only when running unrestricted is a deliberate choice — passing an
30
+ * explicit `policy: allowAllPolicy()` says the same thing and is clearer.
31
+ */
32
+ silencePolicyWarning?: boolean;
33
+ /**
34
+ * Optional cost estimator. Called once per successful call/embedding with the
35
+ * final usage; its return value (USD) is stored on the telemetry record's
36
+ * `costUsd`. Return undefined to leave a call uncosted. Pricing tables live in
37
+ * the app, not the library — this is only the seam to attach one.
38
+ */
39
+ pricing?(info: {
40
+ provider: string;
41
+ model: string;
42
+ usage: Usage;
43
+ }): number | undefined;
20
44
  }
45
+ /** Test seam: reset the once-per-process startup notice. */
46
+ export declare function resetPolicyWarningForTests(): void;
21
47
  export declare class AIHandler {
22
48
  private opts;
23
49
  private active;
@@ -28,11 +54,45 @@ export declare class AIHandler {
28
54
  constructor(opts: HandlerOptions);
29
55
  chat(conn: Connection, req: ChatRequest): Promise<ChatResult>;
30
56
  stream(conn: Connection, req: ChatRequest): AsyncIterable<StreamEvent>;
57
+ /**
58
+ * The shared pre-call preamble: policy check → key resolution → `beforeCall`
59
+ * hook. Every entry point (`stream`, `embed`, `listModels`/`testConnection`)
60
+ * runs exactly this sequence, so it lives once. Failures are *returned*, not
61
+ * recorded — each caller owns its own telemetry shape (`recordFailure` for
62
+ * chat/probe, `recordEmbed` for embeddings) and records the returned error.
63
+ */
64
+ private preflight;
65
+ /** Record a failed call and emit the single redacted `error` event for it. */
66
+ private failStream;
31
67
  listModels(conn: Connection): Promise<ModelInfo[]>;
32
68
  testConnection(conn: Connection): Promise<{
33
69
  ok: boolean;
34
70
  message: string;
35
71
  }>;
72
+ /**
73
+ * Warm a connection so the first real call doesn't pay cold DNS/TLS and
74
+ * (for a local daemon) process spin-up on the hot path. Runs a lightweight
75
+ * health probe and never throws — call it once at boot. Combined with a CJS
76
+ * `require` path it removes the cold-start "offline" false negative.
77
+ */
78
+ prewarm(conn: Connection): Promise<void>;
79
+ /**
80
+ * Chat that returns typed, validated output. Requests JSON mode, extracts the
81
+ * JSON from the reply, and validates it against any Standard Schema validator
82
+ * (Zod / Valibot / ArkType). On a validation miss it performs exactly one
83
+ * corrective retry that shows the model its error, then throws
84
+ * `invalid_response` if still invalid. No schema library is bundled — the
85
+ * caller supplies the schema.
86
+ */
87
+ chatParsed<T>(conn: Connection, req: ChatRequest, schema: StandardSchemaV1<T>): Promise<ParsedResult<T>>;
88
+ /**
89
+ * Produce embeddings through the same governed pipeline as chat: policy check,
90
+ * key resolution, `beforeCall` hook, concurrency, and exactly one redacted
91
+ * telemetry record. Throws a typed `invalid_request` error for providers whose
92
+ * adapter has no `embed` (Anthropic, Google) rather than failing silently.
93
+ */
94
+ embed(conn: Connection, req: EmbedRequest): Promise<EmbedResult>;
95
+ private recordEmbed;
36
96
  private runProbe;
37
97
  private resolveConnection;
38
98
  private acquire;
package/dist/handler.js CHANGED
@@ -1,9 +1,20 @@
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 { promptChars, sleep } from './util.js';
8
+ /**
9
+ * A *startup* notice, so it fires once per process rather than once per handler
10
+ * — an app that builds a handler per request would otherwise flood its logs
11
+ * with a message that says the same thing every time.
12
+ */
13
+ let warnedNoPolicy = false;
14
+ /** Test seam: reset the once-per-process startup notice. */
15
+ export function resetPolicyWarningForTests() {
16
+ warnedNoPolicy = false;
17
+ }
7
18
  export class AIHandler {
8
19
  opts;
9
20
  active = 0;
@@ -14,6 +25,15 @@ export class AIHandler {
14
25
  constructor(opts) {
15
26
  this.opts = opts;
16
27
  this.policy = opts.policy ?? allowAllPolicy();
28
+ // `allowAllPolicy()` stays the default — a library must not decide an app's
29
+ // governance — but "no policy configured" should be visible at runtime, not
30
+ // only in a README paragraph. Passing an explicit policy (including
31
+ // `allowAllPolicy()`) is the opt-out.
32
+ if (!opts.policy && !opts.silencePolicyWarning && !warnedNoPolicy) {
33
+ warnedNoPolicy = true;
34
+ globalThis.console?.warn?.('[ai-nugget] No GovernancePolicy configured — every provider/model is allowed. ' +
35
+ 'Pass `policy` (e.g. allowlistPolicy(...)), or `policy: allowAllPolicy()` / `silencePolicyWarning: true` to accept this deliberately.');
36
+ }
17
37
  }
18
38
  async chat(conn, req) {
19
39
  let result;
@@ -31,42 +51,13 @@ export class AIHandler {
31
51
  const callId = createCallId();
32
52
  const startedAt = Date.now();
33
53
  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
- }
54
+ const preflight = await this.preflight(callId, conn, req.model, req.metadata);
55
+ if (!preflight.ok)
56
+ return yield* this.failStream(callId, conn, req, startedAt, 1, preflight.error);
57
+ // One idempotency key per logical call, reused across every retry attempt,
58
+ // so a provider that supports it collapses a re-sent generation instead of
59
+ // billing it twice (see `supportsIdempotencyKey` in the profile table).
60
+ const resolved = withIdempotencyKey(preflight.resolved, callId);
70
61
  let acquired = false;
71
62
  let recorded = false;
72
63
  let attempt = 0;
@@ -101,11 +92,11 @@ export class AIHandler {
101
92
  }
102
93
  catch (errorValue) {
103
94
  const error = fromUnknown(errorValue, conn.provider);
95
+ // `emittedOutput` stops retries once any byte has reached the caller:
96
+ // never silently re-run a partially delivered answer.
104
97
  if (!error.retryable || attempt >= maxAttempts || req.signal?.aborted || emittedOutput) {
105
- await this.recordFailure(callId, conn, req, startedAt, attempt, error);
106
98
  recorded = true;
107
- yield { type: 'error', error: this.redactedError(error) };
108
- return;
99
+ return yield* this.failStream(callId, conn, req, startedAt, attempt, error);
109
100
  }
110
101
  const delayMs = this.retryDelay(error, attempt);
111
102
  yield { type: 'retry', attempt, reason: error.kind, delayMs };
@@ -113,21 +104,15 @@ export class AIHandler {
113
104
  await sleep(delayMs, req.signal);
114
105
  }
115
106
  catch (sleepError) {
116
- const canceled = fromUnknown(sleepError, conn.provider);
117
- await this.recordFailure(callId, conn, req, startedAt, attempt, canceled);
118
107
  recorded = true;
119
- yield { type: 'error', error: this.redactedError(canceled) };
120
- return;
108
+ return yield* this.failStream(callId, conn, req, startedAt, attempt, fromUnknown(sleepError, conn.provider));
121
109
  }
122
110
  }
123
111
  }
124
112
  }
125
113
  catch (errorValue) {
126
- const error = fromUnknown(errorValue, conn.provider);
127
- await this.recordFailure(callId, conn, req, startedAt, Math.max(1, attempt), error);
128
114
  recorded = true;
129
- yield { type: 'error', error: this.redactedError(error) };
130
- return;
115
+ return yield* this.failStream(callId, conn, req, startedAt, Math.max(1, attempt), fromUnknown(errorValue, conn.provider));
131
116
  }
132
117
  finally {
133
118
  if (acquired)
@@ -137,6 +122,52 @@ export class AIHandler {
137
122
  }
138
123
  }
139
124
  }
125
+ /**
126
+ * The shared pre-call preamble: policy check → key resolution → `beforeCall`
127
+ * hook. Every entry point (`stream`, `embed`, `listModels`/`testConnection`)
128
+ * runs exactly this sequence, so it lives once. Failures are *returned*, not
129
+ * recorded — each caller owns its own telemetry shape (`recordFailure` for
130
+ * chat/probe, `recordEmbed` for embeddings) and records the returned error.
131
+ */
132
+ async preflight(callId, conn, model, metadata) {
133
+ const policyResult = this.policy.checkModel(conn.provider, model);
134
+ if (!policyResult.allowed) {
135
+ return { ok: false, error: new AIError(policyResult.reason, { kind: 'policy_blocked', retryable: false, provider: conn.provider }) };
136
+ }
137
+ let resolved;
138
+ try {
139
+ resolved = await this.resolveConnection(conn);
140
+ }
141
+ catch (errorValue) {
142
+ // Key resolution failures (missing/locked/denied) are a recorded outcome,
143
+ // not an uncaught throw — the traceability contract covers them too.
144
+ return { ok: false, error: fromUnknown(errorValue, conn.provider) };
145
+ }
146
+ try {
147
+ const info = {
148
+ callId,
149
+ connection: safeConnection(conn),
150
+ provider: conn.provider,
151
+ model,
152
+ metadata,
153
+ resolved: safeResolved(resolved),
154
+ };
155
+ if (await this.opts.hooks?.beforeCall?.(info) === 'deny') {
156
+ return { ok: false, error: new AIError('Call denied by beforeCall hook', { kind: 'policy_blocked', retryable: false, provider: conn.provider }) };
157
+ }
158
+ }
159
+ catch (errorValue) {
160
+ // A throwing beforeCall hook is a recorded, redacted outcome too — not
161
+ // an uncaught throw that skips telemetry and the redaction guarantee.
162
+ return { ok: false, error: fromUnknown(errorValue, conn.provider) };
163
+ }
164
+ return { ok: true, resolved };
165
+ }
166
+ /** Record a failed call and emit the single redacted `error` event for it. */
167
+ async *failStream(callId, conn, req, startedAt, attempts, error) {
168
+ await this.recordFailure(callId, conn, req, startedAt, attempts, error);
169
+ yield { type: 'error', error: this.redactedError(error) };
170
+ }
140
171
  async listModels(conn) {
141
172
  return this.runProbe(conn, '__listModels__', async (resolved) => {
142
173
  const adapter = adapterFor(conn.provider, conn.baseUrl);
@@ -161,23 +192,111 @@ export class AIHandler {
161
192
  return { ok: false, message: error instanceof Error ? this.redact(error.message) : 'Connection failed' };
162
193
  }
163
194
  }
164
- async runProbe(conn, operation, action) {
195
+ /**
196
+ * Warm a connection so the first real call doesn't pay cold DNS/TLS and
197
+ * (for a local daemon) process spin-up on the hot path. Runs a lightweight
198
+ * health probe and never throws — call it once at boot. Combined with a CJS
199
+ * `require` path it removes the cold-start "offline" false negative.
200
+ */
201
+ async prewarm(conn) {
202
+ try {
203
+ await this.testConnection(conn);
204
+ }
205
+ catch {
206
+ // Warm-up only: a failure here just means the first real call pays the cost.
207
+ }
208
+ }
209
+ /**
210
+ * Chat that returns typed, validated output. Requests JSON mode, extracts the
211
+ * JSON from the reply, and validates it against any Standard Schema validator
212
+ * (Zod / Valibot / ArkType). On a validation miss it performs exactly one
213
+ * corrective retry that shows the model its error, then throws
214
+ * `invalid_response` if still invalid. No schema library is bundled — the
215
+ * caller supplies the schema.
216
+ */
217
+ async chatParsed(conn, req, schema) {
218
+ const base = { ...req, responseFormat: req.responseFormat ?? { type: 'json' } };
219
+ let result = await this.chat(conn, base);
220
+ let validated = await validateWithSchema(schema, result.text);
221
+ if (validated.ok)
222
+ return { data: validated.value, result };
223
+ const retry = {
224
+ ...base,
225
+ messages: [
226
+ ...base.messages,
227
+ { role: 'assistant', content: result.text },
228
+ { role: 'user', content: `That response did not match the required schema (${validated.message}). Reply again with ONLY a valid JSON value that matches it.` },
229
+ ],
230
+ };
231
+ result = await this.chat(conn, retry);
232
+ validated = await validateWithSchema(schema, result.text);
233
+ if (validated.ok)
234
+ return { data: validated.value, result };
235
+ throw new AIError(`Model output failed schema validation: ${validated.message}`, { kind: 'invalid_response', provider: conn.provider });
236
+ }
237
+ /**
238
+ * Produce embeddings through the same governed pipeline as chat: policy check,
239
+ * key resolution, `beforeCall` hook, concurrency, and exactly one redacted
240
+ * telemetry record. Throws a typed `invalid_request` error for providers whose
241
+ * adapter has no `embed` (Anthropic, Google) rather than failing silently.
242
+ */
243
+ async embed(conn, req) {
165
244
  const callId = createCallId();
166
245
  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);
246
+ const inputChars = (Array.isArray(req.input) ? req.input : [req.input]).reduce((sum, text) => sum + text.length, 0);
247
+ const fail = async (error) => {
248
+ await this.recordEmbed(callId, conn, req, startedAt, { estimated: true }, inputChars, error);
172
249
  throw this.redactedError(error);
250
+ };
251
+ const preflight = await this.preflight(callId, conn, req.model, req.metadata);
252
+ if (!preflight.ok)
253
+ return fail(preflight.error);
254
+ const resolved = preflight.resolved;
255
+ const adapter = adapterFor(conn.provider, conn.baseUrl);
256
+ if (!adapter.embed) {
257
+ return fail(new AIError(`Provider ${conn.provider} does not support embeddings`, { kind: 'invalid_request', retryable: false, provider: conn.provider }));
173
258
  }
174
- let resolved;
259
+ await this.acquire(req.signal);
175
260
  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
- }
261
+ const result = await adapter.embed(resolved, req);
262
+ await this.recordEmbed(callId, conn, req, startedAt, result.usage, inputChars);
263
+ return result;
264
+ }
265
+ catch (errorValue) {
266
+ return fail(fromUnknown(errorValue, conn.provider));
267
+ }
268
+ finally {
269
+ this.release();
270
+ }
271
+ }
272
+ async recordEmbed(callId, conn, req, startedAt, usage, inputChars, error) {
273
+ const costUsd = error ? undefined : this.opts.pricing?.({ provider: conn.provider, model: req.model, usage });
274
+ await this.record({
275
+ callId,
276
+ connectionId: conn.id,
277
+ provider: conn.provider,
278
+ model: req.model,
279
+ startedAt,
280
+ timing: { firstTokenMs: null, totalMs: Date.now() - startedAt },
281
+ usage,
282
+ finishReason: error ? (error.kind === 'canceled' ? 'canceled' : 'error') : 'stop',
283
+ ...(error ? { error: { kind: error.kind, status: error.status, message: error.message } } : {}),
284
+ attempts: 1,
285
+ metadata: { ...req.metadata, operation: '__embed__' },
286
+ promptChars: inputChars,
287
+ responseChars: 0,
288
+ ...(costUsd !== undefined ? { costUsd } : {}),
289
+ });
290
+ }
291
+ async runProbe(conn, operation, action) {
292
+ const callId = createCallId();
293
+ const startedAt = Date.now();
294
+ const req = { model: operation, messages: [], metadata: { operation } };
295
+ try {
296
+ const preflight = await this.preflight(callId, conn, operation, req.metadata);
297
+ if (!preflight.ok)
298
+ throw preflight.error;
299
+ const resolved = preflight.resolved;
181
300
  await this.acquire(undefined);
182
301
  try {
183
302
  const value = await action(resolved);
@@ -278,6 +397,7 @@ export class AIHandler {
278
397
  return Math.round(exponential * (0.75 + Math.random() * 0.5));
279
398
  }
280
399
  async recordSuccess(callId, conn, req, startedAt, attempts, result) {
400
+ const costUsd = this.opts.pricing?.({ provider: conn.provider, model: req.model, usage: result.usage });
281
401
  await this.record({
282
402
  callId,
283
403
  connectionId: conn.id,
@@ -291,6 +411,7 @@ export class AIHandler {
291
411
  metadata: req.metadata,
292
412
  promptChars: promptChars(req.messages),
293
413
  responseChars: result.text.length,
414
+ ...(costUsd !== undefined ? { costUsd } : {}),
294
415
  });
295
416
  }
296
417
  async recordFailure(callId, conn, req, startedAt, attempts, error) {
@@ -371,6 +492,65 @@ function redactMetadata(metadata, redact) {
371
492
  function trimSlash(value) {
372
493
  return value.replace(/\/+$/, '');
373
494
  }
495
+ /**
496
+ * Drops the resolved API key *and* neutralizes the auth headers built from it,
497
+ * so a `beforeCall` hook can inspect the endpoint but never the secret.
498
+ * `keyRef` is scrubbed too — a `{ kind: 'literal' }` ref carries the plaintext
499
+ * key, and hooks routinely log the whole object.
500
+ */
501
+ function safeResolved(resolved) {
502
+ const { apiKey: _apiKey, headers, ...rest } = resolved;
503
+ return { ...rest, ...safeKeyRef(resolved), headers: maskAuthHeaders(headers) };
504
+ }
505
+ /**
506
+ * The caller's own `Connection`, with any plaintext `keyRef` masked. Hooks get
507
+ * the connection so they can key off `id`/`provider`/`baseUrl`; they must never
508
+ * be handed the secret, since logging `info.connection` for audit is the
509
+ * obvious thing to do and would otherwise ship the key in clear text.
510
+ */
511
+ function safeConnection(conn) {
512
+ return { ...conn, ...safeKeyRef(conn) };
513
+ }
514
+ function safeKeyRef(conn) {
515
+ if (conn.keyRef?.kind !== 'literal')
516
+ return conn.keyRef === undefined ? {} : { keyRef: conn.keyRef };
517
+ return { keyRef: { kind: 'literal', value: REDACTED } };
518
+ }
519
+ const REDACTED = '[REDACTED]';
520
+ /** Header names whose value is derived from the API key by {@link applyAuth}. */
521
+ const AUTH_HEADERS = new Set(['authorization', 'x-api-key', 'x-goog-api-key', 'api-key']);
522
+ function maskAuthHeaders(headers) {
523
+ const masked = {};
524
+ for (const [name, value] of Object.entries(headers)) {
525
+ masked[name] = AUTH_HEADERS.has(name.toLowerCase()) ? REDACTED : value;
526
+ }
527
+ return masked;
528
+ }
529
+ /**
530
+ * Attach a per-call `Idempotency-Key` for providers that honor one, so a retry
531
+ * after a dropped connection can't be billed as a second generation. Only set
532
+ * where the profile declares support, and never overrides a caller-supplied
533
+ * header.
534
+ */
535
+ function withIdempotencyKey(resolved, callId) {
536
+ const profile = profileFor(resolved.provider, resolved.baseUrl);
537
+ if (!profile.quirks?.supportsIdempotencyKey)
538
+ return resolved;
539
+ if (Object.keys(resolved.headers).some((name) => name.toLowerCase() === 'idempotency-key'))
540
+ return resolved;
541
+ return { ...resolved, headers: { ...resolved.headers, 'idempotency-key': callId } };
542
+ }
543
+ async function validateWithSchema(schema, text) {
544
+ const value = extractJson(text);
545
+ if (value === null)
546
+ return { ok: false, message: 'no JSON found in model output' };
547
+ const result = await schema['~standard'].validate(value);
548
+ if (result.issues) {
549
+ const message = result.issues.map((issue) => issue.message).join('; ');
550
+ return { ok: false, message: message || 'validation failed' };
551
+ }
552
+ return { ok: true, value: result.value };
553
+ }
374
554
  function createCallId() {
375
555
  return globalThis.crypto?.randomUUID?.() ?? `call_${Date.now()}_${Math.random().toString(36).slice(2)}`;
376
556
  }
package/dist/redact.d.ts CHANGED
@@ -1,4 +1,22 @@
1
1
  import type { Redactor } from './types.js';
2
+ /**
3
+ * Prefixed secret formats. Each pattern is anchored on a vendor prefix that is
4
+ * distinctive enough that a match is a secret, not a coincidence.
5
+ */
6
+ export declare const SECRET_PATTERNS: RegExp[];
7
+ /**
8
+ * Unprefixed/opaque secrets — Azure OpenAI `api-key` values, AWS *secret*
9
+ * access keys, generic bearer/session tokens — carry no distinctive prefix, so
10
+ * matching them on shape alone would redact commit SHAs, content hashes, and
11
+ * base64 payloads. These patterns instead anchor on the *label* that precedes
12
+ * the value (`api-key: …`, `"aws_secret_access_key": "…"`, `password=…`) and
13
+ * redact only the value, keeping the label readable in logs.
14
+ *
15
+ * Exact-match registration (`SessionRedactor.addSecret`, which the handler does
16
+ * for every resolved key) remains the only guaranteed catch for a bare,
17
+ * unlabeled, unprefixed secret.
18
+ */
19
+ export declare const LABELED_SECRET_PATTERNS: RegExp[];
2
20
  export declare function createDefaultRedactor(extraSecrets?: Iterable<string>): Redactor;
3
21
  export declare class SessionRedactor implements Redactor {
4
22
  private secrets;
package/dist/redact.js CHANGED
@@ -1,4 +1,8 @@
1
- const SECRET_PATTERNS = [
1
+ /**
2
+ * Prefixed secret formats. Each pattern is anchored on a vendor prefix that is
3
+ * distinctive enough that a match is a secret, not a coincidence.
4
+ */
5
+ export const SECRET_PATTERNS = [
2
6
  /sk-[A-Za-z0-9_-]{20,}/g,
3
7
  /sk-proj-[A-Za-z0-9_-]{20,}/g,
4
8
  /sk-ant-[A-Za-z0-9_-]{20,}/g,
@@ -20,6 +24,27 @@ const SECRET_PATTERNS = [
20
24
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
21
25
  /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
22
26
  ];
27
+ /**
28
+ * Unprefixed/opaque secrets — Azure OpenAI `api-key` values, AWS *secret*
29
+ * access keys, generic bearer/session tokens — carry no distinctive prefix, so
30
+ * matching them on shape alone would redact commit SHAs, content hashes, and
31
+ * base64 payloads. These patterns instead anchor on the *label* that precedes
32
+ * the value (`api-key: …`, `"aws_secret_access_key": "…"`, `password=…`) and
33
+ * redact only the value, keeping the label readable in logs.
34
+ *
35
+ * Exact-match registration (`SessionRedactor.addSecret`, which the handler does
36
+ * for every resolved key) remains the only guaranteed catch for a bare,
37
+ * unlabeled, unprefixed secret.
38
+ */
39
+ export const LABELED_SECRET_PATTERNS = [
40
+ new RegExp(
41
+ // Lookbehind rather than `\b` so an underscore-joined prefix still matches
42
+ // the label (`aws_secret_access_key`, `azure_api_key`).
43
+ 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)` +
44
+ // Optional closing quote handles JSON keys (`"api_key": "…"`).
45
+ String.raw `(["']?\s*[:=]\s*|\s+)` +
46
+ String.raw `(["']?)([A-Za-z0-9/+_.~-]{16,}={0,2})\3`, 'gi'),
47
+ ];
23
48
  export function createDefaultRedactor(extraSecrets = []) {
24
49
  const secrets = new Set([...extraSecrets].filter((value) => value.length >= 6));
25
50
  return {
@@ -27,6 +52,9 @@ export function createDefaultRedactor(extraSecrets = []) {
27
52
  let output = text;
28
53
  for (const pattern of SECRET_PATTERNS)
29
54
  output = output.replace(pattern, '[REDACTED]');
55
+ for (const pattern of LABELED_SECRET_PATTERNS) {
56
+ output = output.replace(pattern, (_match, label, separator, quote) => `${label}${separator}${quote}[REDACTED]${quote}`);
57
+ }
30
58
  for (const secret of secrets)
31
59
  output = output.split(secret).join('[REDACTED]');
32
60
  return output;
@@ -1,7 +1,9 @@
1
- export declare function withTimeout(ms: number, external?: AbortSignal): {
1
+ export declare function withTimeout(ms: number, external?: AbortSignal, idleMs?: number): {
2
2
  signal: AbortSignal;
3
3
  done(): void;
4
4
  timedOut(): boolean;
5
+ /** Reset the idle timer. No-op unless `idleMs` was supplied. Call on each chunk. */
6
+ bump(): void;
5
7
  };
6
8
  export declare function fetchJson(url: string, init: RequestInit & {
7
9
  timeoutMs: number;
package/dist/transport.js CHANGED
@@ -1,11 +1,25 @@
1
1
  import { AIError, classify, fromUnknown } from './errors.js';
2
- export function withTimeout(ms, external) {
2
+ export function withTimeout(ms, external, idleMs) {
3
3
  const controller = new AbortController();
4
4
  let didTimeOut = false;
5
5
  const timer = setTimeout(() => {
6
6
  didTimeOut = true;
7
7
  controller.abort(new AIError(`Request timed out after ${ms}ms`, { kind: 'timeout' }));
8
8
  }, ms);
9
+ // Optional idle timeout: aborts only when no chunk has arrived for `idleMs`,
10
+ // rearmed on every `bump()`. Keeps a long but healthy stream alive while the
11
+ // total `ms` ceiling still bounds a truly stuck call.
12
+ let idleTimer;
13
+ const armIdle = () => {
14
+ if (idleMs === undefined)
15
+ return;
16
+ if (idleTimer)
17
+ clearTimeout(idleTimer);
18
+ idleTimer = setTimeout(() => {
19
+ didTimeOut = true;
20
+ controller.abort(new AIError(`Request idle for ${idleMs}ms`, { kind: 'timeout' }));
21
+ }, idleMs);
22
+ };
9
23
  const onAbort = () => controller.abort(external?.reason);
10
24
  if (external) {
11
25
  if (external.aborted)
@@ -13,15 +27,21 @@ export function withTimeout(ms, external) {
13
27
  else
14
28
  external.addEventListener('abort', onAbort, { once: true });
15
29
  }
30
+ armIdle();
16
31
  return {
17
32
  signal: controller.signal,
18
33
  done() {
19
34
  clearTimeout(timer);
35
+ if (idleTimer)
36
+ clearTimeout(idleTimer);
20
37
  external?.removeEventListener('abort', onAbort);
21
38
  },
22
39
  timedOut() {
23
40
  return didTimeOut;
24
41
  },
42
+ bump() {
43
+ armIdle();
44
+ },
25
45
  };
26
46
  }
27
47
  export async function fetchJson(url, init) {