@jxburros/ai-nugget 0.4.1 → 0.7.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 (187) hide show
  1. package/README.md +158 -382
  2. package/UPGRADING.md +182 -0
  3. package/dist/adapters/engines/anthropic.d.ts +7 -1
  4. package/dist/adapters/engines/anthropic.js +82 -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 +80 -29
  9. package/dist/adapters/engines/ollama.d.ts +2 -1
  10. package/dist/adapters/engines/ollama.js +57 -15
  11. package/dist/adapters/engines/openaiChat.d.ts +8 -1
  12. package/dist/adapters/engines/openaiChat.js +109 -29
  13. package/dist/adapters/profiles.d.ts +21 -1
  14. package/dist/adapters/profiles.js +12 -2
  15. package/dist/agent/loop.d.ts +45 -1
  16. package/dist/agent/loop.js +117 -12
  17. package/dist/cjs/adapters/engines/anthropic.js +326 -0
  18. package/dist/cjs/adapters/engines/base.js +147 -0
  19. package/dist/cjs/adapters/engines/google.js +291 -0
  20. package/dist/cjs/adapters/engines/ollama.js +205 -0
  21. package/dist/cjs/adapters/engines/openaiChat.js +312 -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 +373 -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 +586 -0
  30. package/dist/cjs/index.js +28 -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/reasoning.js +159 -0
  36. package/dist/cjs/redact.js +78 -0
  37. package/dist/cjs/tokens.js +22 -0
  38. package/dist/cjs/transport.js +172 -0
  39. package/dist/cjs/types.js +6 -0
  40. package/dist/cjs/util.js +126 -0
  41. package/dist/errors.js +25 -1
  42. package/dist/handler.d.ts +67 -1
  43. package/dist/handler.js +266 -61
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +1 -0
  46. package/dist/reasoning.d.ts +45 -0
  47. package/dist/reasoning.js +154 -0
  48. package/dist/redact.d.ts +18 -0
  49. package/dist/redact.js +29 -1
  50. package/dist/transport.d.ts +3 -1
  51. package/dist/transport.js +21 -1
  52. package/dist/types.d.ts +112 -2
  53. package/dist/util.d.ts +22 -0
  54. package/dist/util.js +59 -0
  55. package/nugget/VERSION.txt +2 -2
  56. package/nugget/dist/adapters/engines/anthropic.d.ts +14 -0
  57. package/nugget/dist/adapters/engines/anthropic.d.ts.map +1 -0
  58. package/nugget/dist/adapters/engines/anthropic.js +323 -0
  59. package/nugget/dist/adapters/engines/anthropic.js.map +1 -0
  60. package/nugget/dist/adapters/engines/base.d.ts +50 -0
  61. package/nugget/dist/adapters/engines/base.d.ts.map +1 -0
  62. package/nugget/dist/adapters/engines/base.js +137 -0
  63. package/nugget/dist/adapters/engines/base.js.map +1 -0
  64. package/nugget/dist/adapters/engines/google.d.ts +24 -0
  65. package/nugget/dist/adapters/engines/google.d.ts.map +1 -0
  66. package/nugget/dist/adapters/engines/google.js +288 -0
  67. package/nugget/dist/adapters/engines/google.js.map +1 -0
  68. package/nugget/dist/adapters/engines/ollama.d.ts +11 -0
  69. package/nugget/dist/adapters/engines/ollama.d.ts.map +1 -0
  70. package/nugget/dist/adapters/engines/ollama.js +202 -0
  71. package/nugget/dist/adapters/engines/ollama.js.map +1 -0
  72. package/nugget/dist/adapters/engines/openaiChat.d.ts +22 -0
  73. package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -0
  74. package/nugget/dist/adapters/engines/openaiChat.js +308 -0
  75. package/nugget/dist/adapters/engines/openaiChat.js.map +1 -0
  76. package/nugget/dist/adapters/index.d.ts +5 -0
  77. package/nugget/dist/adapters/index.d.ts.map +1 -0
  78. package/nugget/dist/adapters/index.js +17 -0
  79. package/nugget/dist/adapters/index.js.map +1 -0
  80. package/nugget/dist/adapters/profiles.d.ts +62 -0
  81. package/nugget/dist/adapters/profiles.d.ts.map +1 -0
  82. package/nugget/dist/adapters/profiles.js +121 -0
  83. package/nugget/dist/adapters/profiles.js.map +1 -0
  84. package/nugget/dist/agent/index.d.ts +3 -0
  85. package/nugget/dist/agent/index.d.ts.map +1 -0
  86. package/nugget/dist/agent/index.js +3 -0
  87. package/nugget/dist/agent/index.js.map +1 -0
  88. package/nugget/dist/agent/loop.d.ts +114 -0
  89. package/nugget/dist/agent/loop.d.ts.map +1 -0
  90. package/nugget/dist/agent/loop.js +371 -0
  91. package/nugget/dist/agent/loop.js.map +1 -0
  92. package/nugget/dist/agent/tools.d.ts +29 -0
  93. package/nugget/dist/agent/tools.d.ts.map +1 -0
  94. package/nugget/dist/agent/tools.js +48 -0
  95. package/nugget/dist/agent/tools.js.map +1 -0
  96. package/nugget/dist/cjs/adapters/engines/anthropic.js +326 -0
  97. package/nugget/dist/cjs/adapters/engines/base.js +147 -0
  98. package/nugget/dist/cjs/adapters/engines/google.js +291 -0
  99. package/nugget/dist/cjs/adapters/engines/ollama.js +205 -0
  100. package/nugget/dist/cjs/adapters/engines/openaiChat.js +312 -0
  101. package/nugget/dist/cjs/adapters/index.js +22 -0
  102. package/nugget/dist/cjs/adapters/profiles.js +125 -0
  103. package/nugget/dist/cjs/agent/index.js +18 -0
  104. package/nugget/dist/cjs/agent/loop.js +373 -0
  105. package/nugget/dist/cjs/agent/tools.js +51 -0
  106. package/nugget/dist/cjs/connect.js +27 -0
  107. package/nugget/dist/cjs/errors.js +110 -0
  108. package/nugget/dist/cjs/handler.js +586 -0
  109. package/nugget/dist/cjs/index.js +28 -0
  110. package/nugget/dist/cjs/json.js +121 -0
  111. package/nugget/dist/cjs/keys.js +81 -0
  112. package/nugget/dist/cjs/package.json +3 -0
  113. package/nugget/dist/cjs/policy.js +42 -0
  114. package/nugget/dist/cjs/reasoning.js +159 -0
  115. package/nugget/dist/cjs/redact.js +78 -0
  116. package/nugget/dist/cjs/tokens.js +22 -0
  117. package/nugget/dist/cjs/transport.js +172 -0
  118. package/nugget/dist/cjs/types.js +6 -0
  119. package/nugget/dist/cjs/util.js +126 -0
  120. package/nugget/dist/connect.d.ts +31 -0
  121. package/nugget/dist/connect.d.ts.map +1 -0
  122. package/nugget/dist/connect.js +25 -0
  123. package/nugget/dist/connect.js.map +1 -0
  124. package/nugget/dist/errors.d.ts +22 -0
  125. package/nugget/dist/errors.d.ts.map +1 -0
  126. package/nugget/dist/errors.js +104 -0
  127. package/nugget/dist/errors.js.map +1 -0
  128. package/nugget/dist/handler.d.ts +114 -0
  129. package/nugget/dist/handler.d.ts.map +1 -0
  130. package/nugget/dist/handler.js +582 -0
  131. package/nugget/dist/handler.js.map +1 -0
  132. package/nugget/dist/index.d.ts +13 -0
  133. package/nugget/dist/index.d.ts.map +1 -0
  134. package/nugget/dist/index.js +13 -0
  135. package/nugget/dist/index.js.map +1 -0
  136. package/nugget/dist/json.d.ts +7 -0
  137. package/nugget/dist/json.d.ts.map +1 -0
  138. package/nugget/dist/json.js +114 -0
  139. package/nugget/dist/json.js.map +1 -0
  140. package/nugget/dist/keys.d.ts +7 -0
  141. package/nugget/dist/keys.d.ts.map +1 -0
  142. package/nugget/dist/keys.js +75 -0
  143. package/nugget/dist/keys.js.map +1 -0
  144. package/nugget/dist/policy.d.ts +6 -0
  145. package/nugget/dist/policy.d.ts.map +1 -0
  146. package/nugget/dist/policy.js +37 -0
  147. package/nugget/dist/policy.js.map +1 -0
  148. package/nugget/dist/reasoning.d.ts +45 -0
  149. package/nugget/dist/reasoning.d.ts.map +1 -0
  150. package/nugget/dist/reasoning.js +154 -0
  151. package/nugget/dist/reasoning.js.map +1 -0
  152. package/nugget/dist/redact.d.ts +26 -0
  153. package/nugget/dist/redact.d.ts.map +1 -0
  154. package/nugget/dist/redact.js +74 -0
  155. package/nugget/dist/redact.js.map +1 -0
  156. package/nugget/dist/tokens.d.ts +5 -0
  157. package/nugget/dist/tokens.d.ts.map +1 -0
  158. package/nugget/dist/tokens.js +18 -0
  159. package/nugget/dist/tokens.js.map +1 -0
  160. package/nugget/dist/transport.d.ts +30 -0
  161. package/nugget/dist/transport.d.ts.map +1 -0
  162. package/nugget/dist/transport.js +165 -0
  163. package/nugget/dist/transport.js.map +1 -0
  164. package/nugget/dist/types.d.ts +301 -0
  165. package/nugget/dist/types.d.ts.map +1 -0
  166. package/nugget/dist/types.js +4 -0
  167. package/nugget/dist/types.js.map +1 -0
  168. package/nugget/dist/util.d.ts +31 -0
  169. package/nugget/dist/util.d.ts.map +1 -0
  170. package/nugget/dist/util.js +114 -0
  171. package/nugget/dist/util.js.map +1 -0
  172. package/nugget/src/adapters/engines/anthropic.ts +78 -37
  173. package/nugget/src/adapters/engines/base.ts +50 -7
  174. package/nugget/src/adapters/engines/google.ts +79 -29
  175. package/nugget/src/adapters/engines/ollama.ts +56 -17
  176. package/nugget/src/adapters/engines/openaiChat.ts +104 -30
  177. package/nugget/src/adapters/profiles.ts +53 -3
  178. package/nugget/src/agent/loop.ts +156 -13
  179. package/nugget/src/errors.ts +25 -1
  180. package/nugget/src/handler.ts +308 -59
  181. package/nugget/src/index.ts +1 -0
  182. package/nugget/src/reasoning.ts +174 -0
  183. package/nugget/src/redact.ts +32 -1
  184. package/nugget/src/transport.ts +20 -1
  185. package/nugget/src/types.ts +113 -1
  186. package/nugget/src/util.ts +64 -0
  187. package/package.json +18 -5
package/dist/handler.js CHANGED
@@ -1,9 +1,21 @@
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';
5
+ import { containsReasoningBlock, createReasoningStripper, stripReasoningBlocks } from './reasoning.js';
4
6
  import { allowAllPolicy } from './policy.js';
5
7
  import { SessionRedactor } from './redact.js';
6
8
  import { promptChars, sleep } from './util.js';
9
+ /**
10
+ * A *startup* notice, so it fires once per process rather than once per handler
11
+ * — an app that builds a handler per request would otherwise flood its logs
12
+ * with a message that says the same thing every time.
13
+ */
14
+ let warnedNoPolicy = false;
15
+ /** Test seam: reset the once-per-process startup notice. */
16
+ export function resetPolicyWarningForTests() {
17
+ warnedNoPolicy = false;
18
+ }
7
19
  export class AIHandler {
8
20
  opts;
9
21
  active = 0;
@@ -14,6 +26,15 @@ export class AIHandler {
14
26
  constructor(opts) {
15
27
  this.opts = opts;
16
28
  this.policy = opts.policy ?? allowAllPolicy();
29
+ // `allowAllPolicy()` stays the default — a library must not decide an app's
30
+ // governance — but "no policy configured" should be visible at runtime, not
31
+ // only in a README paragraph. Passing an explicit policy (including
32
+ // `allowAllPolicy()`) is the opt-out.
33
+ if (!opts.policy && !opts.silencePolicyWarning && !warnedNoPolicy) {
34
+ warnedNoPolicy = true;
35
+ globalThis.console?.warn?.('[ai-nugget] No GovernancePolicy configured — every provider/model is allowed. ' +
36
+ 'Pass `policy` (e.g. allowlistPolicy(...)), or `policy: allowAllPolicy()` / `silencePolicyWarning: true` to accept this deliberately.');
37
+ }
17
38
  }
18
39
  async chat(conn, req) {
19
40
  let result;
@@ -31,42 +52,13 @@ export class AIHandler {
31
52
  const callId = createCallId();
32
53
  const startedAt = Date.now();
33
54
  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
- }
55
+ const preflight = await this.preflight(callId, conn, req.model, req.metadata);
56
+ if (!preflight.ok)
57
+ return yield* this.failStream(callId, conn, req, startedAt, 1, preflight.error);
58
+ // One idempotency key per logical call, reused across every retry attempt,
59
+ // so a provider that supports it collapses a re-sent generation instead of
60
+ // billing it twice (see `supportsIdempotencyKey` in the profile table).
61
+ const resolved = withIdempotencyKey(preflight.resolved, callId);
70
62
  let acquired = false;
71
63
  let recorded = false;
72
64
  let attempt = 0;
@@ -76,8 +68,11 @@ export class AIHandler {
76
68
  const maxAttempts = this.opts.retry?.maxAttempts ?? 3;
77
69
  let emittedStart = false;
78
70
  let emittedOutput = false;
71
+ const stripInline = this.opts.stripInlineReasoning !== false;
79
72
  for (;;) {
80
73
  attempt += 1;
74
+ // One stripper per attempt: a retried stream starts its tag state over.
75
+ const stripper = stripInline ? createReasoningStripper() : null;
81
76
  try {
82
77
  for await (const event of adapter.stream(resolved, req)) {
83
78
  if (event.type === 'start') {
@@ -89,10 +84,31 @@ export class AIHandler {
89
84
  }
90
85
  if (event.type === 'delta' || event.type === 'tool_call')
91
86
  emittedOutput = true;
87
+ if (event.type === 'delta' && stripper) {
88
+ // Inline reasoning never reaches `delta`; it rides the reasoning channel like a
89
+ // provider-native thinking field would.
90
+ const piece = stripper.push(event.text);
91
+ if (piece.reasoning)
92
+ yield { type: 'reasoning', text: piece.reasoning };
93
+ if (piece.visible)
94
+ yield { type: 'delta', text: piece.visible };
95
+ continue;
96
+ }
92
97
  if (event.type === 'done') {
93
- await this.recordSuccess(callId, conn, req, startedAt, attempt, event.result);
98
+ let result = event.result;
99
+ if (stripper) {
100
+ const tail = stripper.end();
101
+ if (tail.reasoning)
102
+ yield { type: 'reasoning', text: tail.reasoning };
103
+ if (tail.visible)
104
+ yield { type: 'delta', text: tail.visible };
105
+ if (containsReasoningBlock(result.text)) {
106
+ result = { ...result, text: stripReasoningBlocks(result.text) };
107
+ }
108
+ }
109
+ await this.recordSuccess(callId, conn, req, startedAt, attempt, result);
94
110
  recorded = true;
95
- yield event;
111
+ yield { ...event, result };
96
112
  return;
97
113
  }
98
114
  yield event;
@@ -101,11 +117,11 @@ export class AIHandler {
101
117
  }
102
118
  catch (errorValue) {
103
119
  const error = fromUnknown(errorValue, conn.provider);
120
+ // `emittedOutput` stops retries once any byte has reached the caller:
121
+ // never silently re-run a partially delivered answer.
104
122
  if (!error.retryable || attempt >= maxAttempts || req.signal?.aborted || emittedOutput) {
105
- await this.recordFailure(callId, conn, req, startedAt, attempt, error);
106
123
  recorded = true;
107
- yield { type: 'error', error: this.redactedError(error) };
108
- return;
124
+ return yield* this.failStream(callId, conn, req, startedAt, attempt, error);
109
125
  }
110
126
  const delayMs = this.retryDelay(error, attempt);
111
127
  yield { type: 'retry', attempt, reason: error.kind, delayMs };
@@ -113,21 +129,15 @@ export class AIHandler {
113
129
  await sleep(delayMs, req.signal);
114
130
  }
115
131
  catch (sleepError) {
116
- const canceled = fromUnknown(sleepError, conn.provider);
117
- await this.recordFailure(callId, conn, req, startedAt, attempt, canceled);
118
132
  recorded = true;
119
- yield { type: 'error', error: this.redactedError(canceled) };
120
- return;
133
+ return yield* this.failStream(callId, conn, req, startedAt, attempt, fromUnknown(sleepError, conn.provider));
121
134
  }
122
135
  }
123
136
  }
124
137
  }
125
138
  catch (errorValue) {
126
- const error = fromUnknown(errorValue, conn.provider);
127
- await this.recordFailure(callId, conn, req, startedAt, Math.max(1, attempt), error);
128
139
  recorded = true;
129
- yield { type: 'error', error: this.redactedError(error) };
130
- return;
140
+ return yield* this.failStream(callId, conn, req, startedAt, Math.max(1, attempt), fromUnknown(errorValue, conn.provider));
131
141
  }
132
142
  finally {
133
143
  if (acquired)
@@ -137,6 +147,52 @@ export class AIHandler {
137
147
  }
138
148
  }
139
149
  }
150
+ /**
151
+ * The shared pre-call preamble: policy check → key resolution → `beforeCall`
152
+ * hook. Every entry point (`stream`, `embed`, `listModels`/`testConnection`)
153
+ * runs exactly this sequence, so it lives once. Failures are *returned*, not
154
+ * recorded — each caller owns its own telemetry shape (`recordFailure` for
155
+ * chat/probe, `recordEmbed` for embeddings) and records the returned error.
156
+ */
157
+ async preflight(callId, conn, model, metadata) {
158
+ const policyResult = this.policy.checkModel(conn.provider, model);
159
+ if (!policyResult.allowed) {
160
+ return { ok: false, error: new AIError(policyResult.reason, { kind: 'policy_blocked', retryable: false, provider: conn.provider }) };
161
+ }
162
+ let resolved;
163
+ try {
164
+ resolved = await this.resolveConnection(conn);
165
+ }
166
+ catch (errorValue) {
167
+ // Key resolution failures (missing/locked/denied) are a recorded outcome,
168
+ // not an uncaught throw — the traceability contract covers them too.
169
+ return { ok: false, error: fromUnknown(errorValue, conn.provider) };
170
+ }
171
+ try {
172
+ const info = {
173
+ callId,
174
+ connection: safeConnection(conn),
175
+ provider: conn.provider,
176
+ model,
177
+ metadata,
178
+ resolved: safeResolved(resolved),
179
+ };
180
+ if (await this.opts.hooks?.beforeCall?.(info) === 'deny') {
181
+ return { ok: false, error: new AIError('Call denied by beforeCall hook', { kind: 'policy_blocked', retryable: false, provider: conn.provider }) };
182
+ }
183
+ }
184
+ catch (errorValue) {
185
+ // A throwing beforeCall hook is a recorded, redacted outcome too — not
186
+ // an uncaught throw that skips telemetry and the redaction guarantee.
187
+ return { ok: false, error: fromUnknown(errorValue, conn.provider) };
188
+ }
189
+ return { ok: true, resolved };
190
+ }
191
+ /** Record a failed call and emit the single redacted `error` event for it. */
192
+ async *failStream(callId, conn, req, startedAt, attempts, error) {
193
+ await this.recordFailure(callId, conn, req, startedAt, attempts, error);
194
+ yield { type: 'error', error: this.redactedError(error) };
195
+ }
140
196
  async listModels(conn) {
141
197
  return this.runProbe(conn, '__listModels__', async (resolved) => {
142
198
  const adapter = adapterFor(conn.provider, conn.baseUrl);
@@ -161,23 +217,111 @@ export class AIHandler {
161
217
  return { ok: false, message: error instanceof Error ? this.redact(error.message) : 'Connection failed' };
162
218
  }
163
219
  }
164
- async runProbe(conn, operation, action) {
220
+ /**
221
+ * Warm a connection so the first real call doesn't pay cold DNS/TLS and
222
+ * (for a local daemon) process spin-up on the hot path. Runs a lightweight
223
+ * health probe and never throws — call it once at boot. Combined with a CJS
224
+ * `require` path it removes the cold-start "offline" false negative.
225
+ */
226
+ async prewarm(conn) {
227
+ try {
228
+ await this.testConnection(conn);
229
+ }
230
+ catch {
231
+ // Warm-up only: a failure here just means the first real call pays the cost.
232
+ }
233
+ }
234
+ /**
235
+ * Chat that returns typed, validated output. Requests JSON mode, extracts the
236
+ * JSON from the reply, and validates it against any Standard Schema validator
237
+ * (Zod / Valibot / ArkType). On a validation miss it performs exactly one
238
+ * corrective retry that shows the model its error, then throws
239
+ * `invalid_response` if still invalid. No schema library is bundled — the
240
+ * caller supplies the schema.
241
+ */
242
+ async chatParsed(conn, req, schema) {
243
+ const base = { ...req, responseFormat: req.responseFormat ?? { type: 'json' } };
244
+ let result = await this.chat(conn, base);
245
+ let validated = await validateWithSchema(schema, result.text);
246
+ if (validated.ok)
247
+ return { data: validated.value, result };
248
+ const retry = {
249
+ ...base,
250
+ messages: [
251
+ ...base.messages,
252
+ { role: 'assistant', content: result.text },
253
+ { role: 'user', content: `That response did not match the required schema (${validated.message}). Reply again with ONLY a valid JSON value that matches it.` },
254
+ ],
255
+ };
256
+ result = await this.chat(conn, retry);
257
+ validated = await validateWithSchema(schema, result.text);
258
+ if (validated.ok)
259
+ return { data: validated.value, result };
260
+ throw new AIError(`Model output failed schema validation: ${validated.message}`, { kind: 'invalid_response', provider: conn.provider });
261
+ }
262
+ /**
263
+ * Produce embeddings through the same governed pipeline as chat: policy check,
264
+ * key resolution, `beforeCall` hook, concurrency, and exactly one redacted
265
+ * telemetry record. Throws a typed `invalid_request` error for providers whose
266
+ * adapter has no `embed` (Anthropic, Google) rather than failing silently.
267
+ */
268
+ async embed(conn, req) {
165
269
  const callId = createCallId();
166
270
  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);
271
+ const inputChars = (Array.isArray(req.input) ? req.input : [req.input]).reduce((sum, text) => sum + text.length, 0);
272
+ const fail = async (error) => {
273
+ await this.recordEmbed(callId, conn, req, startedAt, { estimated: true }, inputChars, error);
172
274
  throw this.redactedError(error);
275
+ };
276
+ const preflight = await this.preflight(callId, conn, req.model, req.metadata);
277
+ if (!preflight.ok)
278
+ return fail(preflight.error);
279
+ const resolved = preflight.resolved;
280
+ const adapter = adapterFor(conn.provider, conn.baseUrl);
281
+ if (!adapter.embed) {
282
+ return fail(new AIError(`Provider ${conn.provider} does not support embeddings`, { kind: 'invalid_request', retryable: false, provider: conn.provider }));
173
283
  }
174
- let resolved;
284
+ await this.acquire(req.signal);
175
285
  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
- }
286
+ const result = await adapter.embed(resolved, req);
287
+ await this.recordEmbed(callId, conn, req, startedAt, result.usage, inputChars);
288
+ return result;
289
+ }
290
+ catch (errorValue) {
291
+ return fail(fromUnknown(errorValue, conn.provider));
292
+ }
293
+ finally {
294
+ this.release();
295
+ }
296
+ }
297
+ async recordEmbed(callId, conn, req, startedAt, usage, inputChars, error) {
298
+ const costUsd = error ? undefined : this.opts.pricing?.({ provider: conn.provider, model: req.model, usage });
299
+ await this.record({
300
+ callId,
301
+ connectionId: conn.id,
302
+ provider: conn.provider,
303
+ model: req.model,
304
+ startedAt,
305
+ timing: { firstTokenMs: null, totalMs: Date.now() - startedAt },
306
+ usage,
307
+ finishReason: error ? (error.kind === 'canceled' ? 'canceled' : 'error') : 'stop',
308
+ ...(error ? { error: { kind: error.kind, status: error.status, message: error.message } } : {}),
309
+ attempts: 1,
310
+ metadata: { ...req.metadata, operation: '__embed__' },
311
+ promptChars: inputChars,
312
+ responseChars: 0,
313
+ ...(costUsd !== undefined ? { costUsd } : {}),
314
+ });
315
+ }
316
+ async runProbe(conn, operation, action) {
317
+ const callId = createCallId();
318
+ const startedAt = Date.now();
319
+ const req = { model: operation, messages: [], metadata: { operation } };
320
+ try {
321
+ const preflight = await this.preflight(callId, conn, operation, req.metadata);
322
+ if (!preflight.ok)
323
+ throw preflight.error;
324
+ const resolved = preflight.resolved;
181
325
  await this.acquire(undefined);
182
326
  try {
183
327
  const value = await action(resolved);
@@ -278,6 +422,7 @@ export class AIHandler {
278
422
  return Math.round(exponential * (0.75 + Math.random() * 0.5));
279
423
  }
280
424
  async recordSuccess(callId, conn, req, startedAt, attempts, result) {
425
+ const costUsd = this.opts.pricing?.({ provider: conn.provider, model: req.model, usage: result.usage });
281
426
  await this.record({
282
427
  callId,
283
428
  connectionId: conn.id,
@@ -291,6 +436,7 @@ export class AIHandler {
291
436
  metadata: req.metadata,
292
437
  promptChars: promptChars(req.messages),
293
438
  responseChars: result.text.length,
439
+ ...(costUsd !== undefined ? { costUsd } : {}),
294
440
  });
295
441
  }
296
442
  async recordFailure(callId, conn, req, startedAt, attempts, error) {
@@ -371,6 +517,65 @@ function redactMetadata(metadata, redact) {
371
517
  function trimSlash(value) {
372
518
  return value.replace(/\/+$/, '');
373
519
  }
520
+ /**
521
+ * Drops the resolved API key *and* neutralizes the auth headers built from it,
522
+ * so a `beforeCall` hook can inspect the endpoint but never the secret.
523
+ * `keyRef` is scrubbed too — a `{ kind: 'literal' }` ref carries the plaintext
524
+ * key, and hooks routinely log the whole object.
525
+ */
526
+ function safeResolved(resolved) {
527
+ const { apiKey: _apiKey, headers, ...rest } = resolved;
528
+ return { ...rest, ...safeKeyRef(resolved), headers: maskAuthHeaders(headers) };
529
+ }
530
+ /**
531
+ * The caller's own `Connection`, with any plaintext `keyRef` masked. Hooks get
532
+ * the connection so they can key off `id`/`provider`/`baseUrl`; they must never
533
+ * be handed the secret, since logging `info.connection` for audit is the
534
+ * obvious thing to do and would otherwise ship the key in clear text.
535
+ */
536
+ function safeConnection(conn) {
537
+ return { ...conn, ...safeKeyRef(conn) };
538
+ }
539
+ function safeKeyRef(conn) {
540
+ if (conn.keyRef?.kind !== 'literal')
541
+ return conn.keyRef === undefined ? {} : { keyRef: conn.keyRef };
542
+ return { keyRef: { kind: 'literal', value: REDACTED } };
543
+ }
544
+ const REDACTED = '[REDACTED]';
545
+ /** Header names whose value is derived from the API key by {@link applyAuth}. */
546
+ const AUTH_HEADERS = new Set(['authorization', 'x-api-key', 'x-goog-api-key', 'api-key']);
547
+ function maskAuthHeaders(headers) {
548
+ const masked = {};
549
+ for (const [name, value] of Object.entries(headers)) {
550
+ masked[name] = AUTH_HEADERS.has(name.toLowerCase()) ? REDACTED : value;
551
+ }
552
+ return masked;
553
+ }
554
+ /**
555
+ * Attach a per-call `Idempotency-Key` for providers that honor one, so a retry
556
+ * after a dropped connection can't be billed as a second generation. Only set
557
+ * where the profile declares support, and never overrides a caller-supplied
558
+ * header.
559
+ */
560
+ function withIdempotencyKey(resolved, callId) {
561
+ const profile = profileFor(resolved.provider, resolved.baseUrl);
562
+ if (!profile.quirks?.supportsIdempotencyKey)
563
+ return resolved;
564
+ if (Object.keys(resolved.headers).some((name) => name.toLowerCase() === 'idempotency-key'))
565
+ return resolved;
566
+ return { ...resolved, headers: { ...resolved.headers, 'idempotency-key': callId } };
567
+ }
568
+ async function validateWithSchema(schema, text) {
569
+ const value = extractJson(text);
570
+ if (value === null)
571
+ return { ok: false, message: 'no JSON found in model output' };
572
+ const result = await schema['~standard'].validate(value);
573
+ if (result.issues) {
574
+ const message = result.issues.map((issue) => issue.message).join('; ');
575
+ return { ok: false, message: message || 'validation failed' };
576
+ }
577
+ return { ok: true, value: result.value };
578
+ }
374
579
  function createCallId() {
375
580
  return globalThis.crypto?.randomUUID?.() ?? `call_${Date.now()}_${Math.random().toString(36).slice(2)}`;
376
581
  }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './types.js';
2
2
  export * from './errors.js';
3
3
  export * from './transport.js';
4
4
  export * from './json.js';
5
+ export * from './reasoning.js';
5
6
  export * from './tokens.js';
6
7
  export * from './keys.js';
7
8
  export * from './connect.js';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export * from './types.js';
2
2
  export * from './errors.js';
3
3
  export * from './transport.js';
4
4
  export * from './json.js';
5
+ export * from './reasoning.js';
5
6
  export * from './tokens.js';
6
7
  export * from './keys.js';
7
8
  export * from './connect.js';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Inline reasoning stripper — keeps a reasoning model's chain of thought out of `delta` events
3
+ * and `ChatResult.text`, routing it to the `reasoning` stream event instead.
4
+ *
5
+ * Reasoning models (deepseek-r1, phi4-reasoning, qwq, …) emit their thinking either in the
6
+ * provider's dedicated field — which every engine already maps to `{ type: 'reasoning' }` — or
7
+ * INLINE in the answer as `<think>…</think>`-style blocks. Inline reasoning routinely quotes the
8
+ * system prompt and tool instructions back verbatim, so an app that streams `delta` straight to
9
+ * a screen, a database or a JSON parser leaks it. Every consumer would otherwise write this
10
+ * state machine, and most would get the split-tag and template-opened cases wrong; the handler
11
+ * runs it once, on every engine, unless `HandlerOptions.stripInlineReasoning` is `false`.
12
+ *
13
+ * Ported from AI Server Studio's `services/reasoningFilter.ts` (2026-09-05), which had to exist
14
+ * because the nugget did not do this.
15
+ *
16
+ * Two subtleties the tests pin:
17
+ * - Tags may be split across chunks, so a trailing partial `<thi` is held back until the next
18
+ * chunk decides whether it opens a tag. An unterminated block is reasoning to the end.
19
+ * - The **orphan close**: a closing tag with no opening one at the head of the stream, because
20
+ * the runtime's chat template emitted `<think>` itself. Handled once per stream, only before
21
+ * any visible text has gone out, and only when the closing tag starts its own line — a
22
+ * `</think>` quoted mid-sentence in a real answer is left alone.
23
+ */
24
+ export interface ReasoningStripResult {
25
+ /** Text safe to show from this chunk (may be empty). */
26
+ visible: string;
27
+ /** Reasoning text removed from this chunk (may be empty). */
28
+ reasoning: string;
29
+ }
30
+ export interface ReasoningStripper {
31
+ /** Feed the next raw delta; returns what may be shown and what was reasoning. */
32
+ push(chunk: string): ReasoningStripResult;
33
+ /** End of stream: flushes any held-back prefix that never became a tag. */
34
+ end(): ReasoningStripResult;
35
+ /** True while inside an unterminated reasoning block. */
36
+ readonly inReasoning: boolean;
37
+ }
38
+ export declare function createReasoningStripper(): ReasoningStripper;
39
+ /** Whole-string form: `{ visible, reasoning }` for already-buffered text. */
40
+ export declare function stripReasoningBlocksDetailed(text: string): ReasoningStripResult;
41
+ /** Whole-string form returning only the visible text. */
42
+ export declare function stripReasoningBlocks(text: string): string;
43
+ /** Cheap pre-check: does `text` carry any inline reasoning tag (open or close)? */
44
+ export declare function containsReasoningBlock(text: string): boolean;
45
+ //# sourceMappingURL=reasoning.d.ts.map
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Inline reasoning stripper — keeps a reasoning model's chain of thought out of `delta` events
3
+ * and `ChatResult.text`, routing it to the `reasoning` stream event instead.
4
+ *
5
+ * Reasoning models (deepseek-r1, phi4-reasoning, qwq, …) emit their thinking either in the
6
+ * provider's dedicated field — which every engine already maps to `{ type: 'reasoning' }` — or
7
+ * INLINE in the answer as `<think>…</think>`-style blocks. Inline reasoning routinely quotes the
8
+ * system prompt and tool instructions back verbatim, so an app that streams `delta` straight to
9
+ * a screen, a database or a JSON parser leaks it. Every consumer would otherwise write this
10
+ * state machine, and most would get the split-tag and template-opened cases wrong; the handler
11
+ * runs it once, on every engine, unless `HandlerOptions.stripInlineReasoning` is `false`.
12
+ *
13
+ * Ported from AI Server Studio's `services/reasoningFilter.ts` (2026-09-05), which had to exist
14
+ * because the nugget did not do this.
15
+ *
16
+ * Two subtleties the tests pin:
17
+ * - Tags may be split across chunks, so a trailing partial `<thi` is held back until the next
18
+ * chunk decides whether it opens a tag. An unterminated block is reasoning to the end.
19
+ * - The **orphan close**: a closing tag with no opening one at the head of the stream, because
20
+ * the runtime's chat template emitted `<think>` itself. Handled once per stream, only before
21
+ * any visible text has gone out, and only when the closing tag starts its own line — a
22
+ * `</think>` quoted mid-sentence in a real answer is left alone.
23
+ */
24
+ const TAG_PAIRS = [
25
+ { open: '<think>', close: '</think>' },
26
+ { open: '<thinking>', close: '</thinking>' },
27
+ { open: '<reasoning>', close: '</reasoning>' },
28
+ { open: '<|begin_of_thought|>', close: '<|end_of_thought|>' },
29
+ ];
30
+ const LONGEST_TAG = Math.max(...TAG_PAIRS.flatMap((p) => [p.open.length, p.close.length]));
31
+ function partialTagSuffixLength(text) {
32
+ const lower = text.toLowerCase();
33
+ const maxLen = Math.min(LONGEST_TAG - 1, lower.length);
34
+ for (let len = maxLen; len >= 1; len -= 1) {
35
+ const tail = lower.slice(lower.length - len);
36
+ if (tail[0] !== '<')
37
+ continue;
38
+ for (const pair of TAG_PAIRS) {
39
+ if (pair.open.startsWith(tail) || pair.close.startsWith(tail))
40
+ return len;
41
+ }
42
+ }
43
+ return 0;
44
+ }
45
+ function closesOwnLine(text, idx) {
46
+ let i = idx - 1;
47
+ while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\r'))
48
+ i -= 1;
49
+ return i < 0 || text[i] === '\n';
50
+ }
51
+ export function createReasoningStripper() {
52
+ let pending = '';
53
+ let inReasoning = false;
54
+ let closeTag = '';
55
+ let emittedVisible = false;
56
+ let orphanConsumed = false;
57
+ function process(input, flushAll) {
58
+ let text = pending + input;
59
+ pending = '';
60
+ let visible = '';
61
+ let reasoning = '';
62
+ while (text.length > 0) {
63
+ const lower = text.toLowerCase();
64
+ if (inReasoning) {
65
+ const idx = lower.indexOf(closeTag);
66
+ if (idx === -1) {
67
+ const hold = flushAll ? 0 : partialTagSuffixLength(text);
68
+ const consumed = text.length - hold;
69
+ reasoning += text.slice(0, consumed);
70
+ pending = text.slice(consumed);
71
+ text = '';
72
+ if (flushAll) {
73
+ reasoning += pending;
74
+ pending = '';
75
+ }
76
+ break;
77
+ }
78
+ reasoning += text.slice(0, idx);
79
+ text = text.slice(idx + closeTag.length);
80
+ inReasoning = false;
81
+ closeTag = '';
82
+ if (text.startsWith('\n'))
83
+ text = text.slice(1);
84
+ continue;
85
+ }
86
+ let openIdx = -1;
87
+ let pair = null;
88
+ for (const p of TAG_PAIRS) {
89
+ const i = lower.indexOf(p.open);
90
+ if (i !== -1 && (openIdx === -1 || i < openIdx)) {
91
+ openIdx = i;
92
+ pair = p;
93
+ }
94
+ }
95
+ let orphanCloseIdx = -1;
96
+ let orphanCloseLen = 0;
97
+ if (!emittedVisible && !orphanConsumed) {
98
+ for (const p of TAG_PAIRS) {
99
+ const i = lower.indexOf(p.close);
100
+ if (i !== -1 && (orphanCloseIdx === -1 || i < orphanCloseIdx) && closesOwnLine(text, i)) {
101
+ orphanCloseIdx = i;
102
+ orphanCloseLen = p.close.length;
103
+ }
104
+ }
105
+ }
106
+ if (orphanCloseIdx !== -1 && (openIdx === -1 || orphanCloseIdx < openIdx)) {
107
+ orphanConsumed = true;
108
+ reasoning += text.slice(0, orphanCloseIdx);
109
+ text = text.slice(orphanCloseIdx + orphanCloseLen).replace(/^\n+/, '');
110
+ continue;
111
+ }
112
+ if (openIdx === -1 || !pair) {
113
+ const hold = flushAll ? 0 : partialTagSuffixLength(text);
114
+ visible += text.slice(0, text.length - hold);
115
+ pending = text.slice(text.length - hold);
116
+ text = '';
117
+ break;
118
+ }
119
+ visible += text.slice(0, openIdx);
120
+ text = text.slice(openIdx + pair.open.length);
121
+ inReasoning = true;
122
+ closeTag = pair.close;
123
+ }
124
+ if (visible.trim().length > 0)
125
+ emittedVisible = true;
126
+ return { visible, reasoning };
127
+ }
128
+ return {
129
+ push: (chunk) => process(chunk, false),
130
+ end: () => process('', true),
131
+ get inReasoning() {
132
+ return inReasoning;
133
+ },
134
+ };
135
+ }
136
+ /** Whole-string form: `{ visible, reasoning }` for already-buffered text. */
137
+ export function stripReasoningBlocksDetailed(text) {
138
+ if (!text)
139
+ return { visible: '', reasoning: '' };
140
+ const stripper = createReasoningStripper();
141
+ const a = stripper.push(text);
142
+ const b = stripper.end();
143
+ return { visible: (a.visible + b.visible).trim(), reasoning: a.reasoning + b.reasoning };
144
+ }
145
+ /** Whole-string form returning only the visible text. */
146
+ export function stripReasoningBlocks(text) {
147
+ return stripReasoningBlocksDetailed(text).visible;
148
+ }
149
+ /** Cheap pre-check: does `text` carry any inline reasoning tag (open or close)? */
150
+ export function containsReasoningBlock(text) {
151
+ const lower = text.toLowerCase();
152
+ return TAG_PAIRS.some((p) => lower.includes(p.open) || lower.includes(p.close));
153
+ }
154
+ //# sourceMappingURL=reasoning.js.map
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;