@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
@@ -1,6 +1,8 @@
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 type {
@@ -9,13 +11,18 @@ import type {
9
11
  ChatRequest,
10
12
  ChatResult,
11
13
  Connection,
14
+ EmbedRequest,
15
+ EmbedResult,
12
16
  GovernancePolicy,
13
17
  KeySource,
14
18
  ModelInfo,
19
+ ParsedResult,
15
20
  Redactor,
16
21
  ResolvedConnection,
22
+ StandardSchemaV1,
17
23
  StreamEvent,
18
24
  TelemetrySink,
25
+ Usage,
19
26
  } from './types.js';
20
27
  import { promptChars, sleep } from './util.js';
21
28
 
@@ -36,7 +43,45 @@ export interface HandlerOptions {
36
43
  afterCall?(record: CallRecord): Promise<void>;
37
44
  };
38
45
  retry?: { maxAttempts?: number; baseDelayMs?: number; maxDelayMs?: number };
46
+ /**
47
+ * Concurrency and pacing limits, enforced by an **in-memory queue on this
48
+ * handler instance only**. There is no shared/external coordination, so N
49
+ * instances behind a load balancer allow N × `maxConcurrent` in flight against
50
+ * the provider. Size these per instance, or put a shared limiter in front if a
51
+ * global ceiling matters.
52
+ */
39
53
  limits?: { maxConcurrent?: number; minIntervalMs?: number };
54
+ /**
55
+ * Silence the one-time startup notice logged when no `policy` is supplied.
56
+ * Set this only when running unrestricted is a deliberate choice — passing an
57
+ * explicit `policy: allowAllPolicy()` says the same thing and is clearer.
58
+ */
59
+ silencePolicyWarning?: boolean;
60
+ /**
61
+ * Strip inline `<think>…</think>`-style reasoning out of `delta` events and `ChatResult.text`,
62
+ * emitting it as `{ type: 'reasoning' }` events instead (default `true`; see `src/reasoning.ts`).
63
+ * Set `false` to receive the raw answer text exactly as the provider sent it.
64
+ */
65
+ stripInlineReasoning?: boolean;
66
+ /**
67
+ * Optional cost estimator. Called once per successful call/embedding with the
68
+ * final usage; its return value (USD) is stored on the telemetry record's
69
+ * `costUsd`. Return undefined to leave a call uncosted. Pricing tables live in
70
+ * the app, not the library — this is only the seam to attach one.
71
+ */
72
+ pricing?(info: { provider: string; model: string; usage: Usage }): number | undefined;
73
+ }
74
+
75
+ /**
76
+ * A *startup* notice, so it fires once per process rather than once per handler
77
+ * — an app that builds a handler per request would otherwise flood its logs
78
+ * with a message that says the same thing every time.
79
+ */
80
+ let warnedNoPolicy = false;
81
+
82
+ /** Test seam: reset the once-per-process startup notice. */
83
+ export function resetPolicyWarningForTests(): void {
84
+ warnedNoPolicy = false;
40
85
  }
41
86
 
42
87
  export class AIHandler {
@@ -48,6 +93,17 @@ export class AIHandler {
48
93
 
49
94
  constructor(private opts: HandlerOptions) {
50
95
  this.policy = opts.policy ?? allowAllPolicy();
96
+ // `allowAllPolicy()` stays the default — a library must not decide an app's
97
+ // governance — but "no policy configured" should be visible at runtime, not
98
+ // only in a README paragraph. Passing an explicit policy (including
99
+ // `allowAllPolicy()`) is the opt-out.
100
+ if (!opts.policy && !opts.silencePolicyWarning && !warnedNoPolicy) {
101
+ warnedNoPolicy = true;
102
+ globalThis.console?.warn?.(
103
+ '[ai-nugget] No GovernancePolicy configured — every provider/model is allowed. ' +
104
+ 'Pass `policy` (e.g. allowlistPolicy(...)), or `policy: allowAllPolicy()` / `silencePolicyWarning: true` to accept this deliberately.',
105
+ );
106
+ }
51
107
  }
52
108
 
53
109
  async chat(conn: Connection, req: ChatRequest): Promise<ChatResult> {
@@ -64,41 +120,13 @@ export class AIHandler {
64
120
  const callId = createCallId();
65
121
  const startedAt = Date.now();
66
122
  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
123
 
75
- let resolved: ResolvedConnection;
76
- try {
77
- resolved = await this.resolveConnection(conn);
78
- } catch (errorValue) {
79
- // Key resolution failures (missing/locked/denied) are a recorded outcome,
80
- // not an uncaught throw — the traceability contract covers them too.
81
- const error = fromUnknown(errorValue, conn.provider);
82
- await this.recordFailure(callId, conn, req, startedAt, 1, error);
83
- yield { type: 'error', error: this.redactedError(error) };
84
- return;
85
- }
86
- const info: CallInfo = { callId, connection: conn, provider: conn.provider, model: req.model, metadata: req.metadata };
87
- try {
88
- if (await this.opts.hooks?.beforeCall?.(info) === 'deny') {
89
- const error = new AIError('Call denied by beforeCall hook', { kind: 'policy_blocked', retryable: false, provider: conn.provider });
90
- await this.recordFailure(callId, conn, req, startedAt, 1, error);
91
- yield { type: 'error', error: this.redactedError(error) };
92
- return;
93
- }
94
- } catch (errorValue) {
95
- // A throwing beforeCall hook is a recorded, redacted outcome too — not
96
- // an uncaught throw that skips telemetry and the redaction guarantee.
97
- const error = fromUnknown(errorValue, conn.provider);
98
- await this.recordFailure(callId, conn, req, startedAt, 1, error);
99
- yield { type: 'error', error: this.redactedError(error) };
100
- return;
101
- }
124
+ const preflight = await this.preflight(callId, conn, req.model, req.metadata);
125
+ if (!preflight.ok) return yield* this.failStream(callId, conn, req, startedAt, 1, preflight.error);
126
+ // One idempotency key per logical call, reused across every retry attempt,
127
+ // so a provider that supports it collapses a re-sent generation instead of
128
+ // billing it twice (see `supportsIdempotencyKey` in the profile table).
129
+ const resolved = withIdempotencyKey(preflight.resolved, callId);
102
130
 
103
131
  let acquired = false;
104
132
  let recorded = false;
@@ -109,8 +137,11 @@ export class AIHandler {
109
137
  const maxAttempts = this.opts.retry?.maxAttempts ?? 3;
110
138
  let emittedStart = false;
111
139
  let emittedOutput = false;
140
+ const stripInline = this.opts.stripInlineReasoning !== false;
112
141
  for (;;) {
113
142
  attempt += 1;
143
+ // One stripper per attempt: a retried stream starts its tag state over.
144
+ const stripper = stripInline ? createReasoningStripper() : null;
114
145
  try {
115
146
  for await (const event of adapter.stream(resolved, req)) {
116
147
  if (event.type === 'start') {
@@ -121,10 +152,27 @@ export class AIHandler {
121
152
  continue;
122
153
  }
123
154
  if (event.type === 'delta' || event.type === 'tool_call') emittedOutput = true;
155
+ if (event.type === 'delta' && stripper) {
156
+ // Inline reasoning never reaches `delta`; it rides the reasoning channel like a
157
+ // provider-native thinking field would.
158
+ const piece = stripper.push(event.text);
159
+ if (piece.reasoning) yield { type: 'reasoning', text: piece.reasoning };
160
+ if (piece.visible) yield { type: 'delta', text: piece.visible };
161
+ continue;
162
+ }
124
163
  if (event.type === 'done') {
125
- await this.recordSuccess(callId, conn, req, startedAt, attempt, event.result);
164
+ let result = event.result;
165
+ if (stripper) {
166
+ const tail = stripper.end();
167
+ if (tail.reasoning) yield { type: 'reasoning', text: tail.reasoning };
168
+ if (tail.visible) yield { type: 'delta', text: tail.visible };
169
+ if (containsReasoningBlock(result.text)) {
170
+ result = { ...result, text: stripReasoningBlocks(result.text) };
171
+ }
172
+ }
173
+ await this.recordSuccess(callId, conn, req, startedAt, attempt, result);
126
174
  recorded = true;
127
- yield event;
175
+ yield { ...event, result };
128
176
  return;
129
177
  }
130
178
  yield event;
@@ -132,31 +180,25 @@ export class AIHandler {
132
180
  throw new AIError('Provider did not emit a done event', { kind: 'invalid_response', provider: conn.provider });
133
181
  } catch (errorValue) {
134
182
  const error = fromUnknown(errorValue, conn.provider);
183
+ // `emittedOutput` stops retries once any byte has reached the caller:
184
+ // never silently re-run a partially delivered answer.
135
185
  if (!error.retryable || attempt >= maxAttempts || req.signal?.aborted || emittedOutput) {
136
- await this.recordFailure(callId, conn, req, startedAt, attempt, error);
137
186
  recorded = true;
138
- yield { type: 'error', error: this.redactedError(error) };
139
- return;
187
+ return yield* this.failStream(callId, conn, req, startedAt, attempt, error);
140
188
  }
141
189
  const delayMs = this.retryDelay(error, attempt);
142
190
  yield { type: 'retry', attempt, reason: error.kind, delayMs };
143
191
  try {
144
192
  await sleep(delayMs, req.signal);
145
193
  } catch (sleepError) {
146
- const canceled = fromUnknown(sleepError, conn.provider);
147
- await this.recordFailure(callId, conn, req, startedAt, attempt, canceled);
148
194
  recorded = true;
149
- yield { type: 'error', error: this.redactedError(canceled) };
150
- return;
195
+ return yield* this.failStream(callId, conn, req, startedAt, attempt, fromUnknown(sleepError, conn.provider));
151
196
  }
152
197
  }
153
198
  }
154
199
  } catch (errorValue) {
155
- const error = fromUnknown(errorValue, conn.provider);
156
- await this.recordFailure(callId, conn, req, startedAt, Math.max(1, attempt), error);
157
200
  recorded = true;
158
- yield { type: 'error', error: this.redactedError(error) };
159
- return;
201
+ return yield* this.failStream(callId, conn, req, startedAt, Math.max(1, attempt), fromUnknown(errorValue, conn.provider));
160
202
  } finally {
161
203
  if (acquired) this.release();
162
204
  if (acquired && !recorded) {
@@ -172,6 +214,59 @@ export class AIHandler {
172
214
  }
173
215
  }
174
216
 
217
+ /**
218
+ * The shared pre-call preamble: policy check → key resolution → `beforeCall`
219
+ * hook. Every entry point (`stream`, `embed`, `listModels`/`testConnection`)
220
+ * runs exactly this sequence, so it lives once. Failures are *returned*, not
221
+ * recorded — each caller owns its own telemetry shape (`recordFailure` for
222
+ * chat/probe, `recordEmbed` for embeddings) and records the returned error.
223
+ */
224
+ private async preflight(
225
+ callId: string,
226
+ conn: Connection,
227
+ model: string,
228
+ metadata: Record<string, unknown> | undefined,
229
+ ): Promise<{ ok: true; resolved: ResolvedConnection } | { ok: false; error: AIError }> {
230
+ const policyResult = this.policy.checkModel(conn.provider, model);
231
+ if (!policyResult.allowed) {
232
+ return { ok: false, error: new AIError(policyResult.reason, { kind: 'policy_blocked', retryable: false, provider: conn.provider }) };
233
+ }
234
+
235
+ let resolved: ResolvedConnection;
236
+ try {
237
+ resolved = await this.resolveConnection(conn);
238
+ } catch (errorValue) {
239
+ // Key resolution failures (missing/locked/denied) are a recorded outcome,
240
+ // not an uncaught throw — the traceability contract covers them too.
241
+ return { ok: false, error: fromUnknown(errorValue, conn.provider) };
242
+ }
243
+
244
+ try {
245
+ const info: CallInfo = {
246
+ callId,
247
+ connection: safeConnection(conn),
248
+ provider: conn.provider,
249
+ model,
250
+ metadata,
251
+ resolved: safeResolved(resolved),
252
+ };
253
+ if (await this.opts.hooks?.beforeCall?.(info) === 'deny') {
254
+ return { ok: false, error: new AIError('Call denied by beforeCall hook', { kind: 'policy_blocked', retryable: false, provider: conn.provider }) };
255
+ }
256
+ } catch (errorValue) {
257
+ // A throwing beforeCall hook is a recorded, redacted outcome too — not
258
+ // an uncaught throw that skips telemetry and the redaction guarantee.
259
+ return { ok: false, error: fromUnknown(errorValue, conn.provider) };
260
+ }
261
+ return { ok: true, resolved };
262
+ }
263
+
264
+ /** Record a failed call and emit the single redacted `error` event for it. */
265
+ private async *failStream(callId: string, conn: Connection, req: ChatRequest, startedAt: number, attempts: number, error: AIError): AsyncGenerator<StreamEvent> {
266
+ await this.recordFailure(callId, conn, req, startedAt, attempts, error);
267
+ yield { type: 'error', error: this.redactedError(error) };
268
+ }
269
+
175
270
  async listModels(conn: Connection): Promise<ModelInfo[]> {
176
271
  return this.runProbe(conn, '__listModels__', async (resolved) => {
177
272
  const adapter = adapterFor(conn.provider, conn.baseUrl);
@@ -196,23 +291,112 @@ export class AIHandler {
196
291
  }
197
292
  }
198
293
 
199
- private async runProbe<T>(conn: Connection, operation: '__listModels__' | '__testConnection__', action: (resolved: ResolvedConnection) => Promise<T>): Promise<T> {
294
+ /**
295
+ * Warm a connection so the first real call doesn't pay cold DNS/TLS and
296
+ * (for a local daemon) process spin-up on the hot path. Runs a lightweight
297
+ * health probe and never throws — call it once at boot. Combined with a CJS
298
+ * `require` path it removes the cold-start "offline" false negative.
299
+ */
300
+ async prewarm(conn: Connection): Promise<void> {
301
+ try {
302
+ await this.testConnection(conn);
303
+ } catch {
304
+ // Warm-up only: a failure here just means the first real call pays the cost.
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Chat that returns typed, validated output. Requests JSON mode, extracts the
310
+ * JSON from the reply, and validates it against any Standard Schema validator
311
+ * (Zod / Valibot / ArkType). On a validation miss it performs exactly one
312
+ * corrective retry that shows the model its error, then throws
313
+ * `invalid_response` if still invalid. No schema library is bundled — the
314
+ * caller supplies the schema.
315
+ */
316
+ async chatParsed<T>(conn: Connection, req: ChatRequest, schema: StandardSchemaV1<T>): Promise<ParsedResult<T>> {
317
+ const base: ChatRequest = { ...req, responseFormat: req.responseFormat ?? { type: 'json' } };
318
+ let result = await this.chat(conn, base);
319
+ let validated = await validateWithSchema(schema, result.text);
320
+ if (validated.ok) return { data: validated.value, result };
321
+
322
+ const retry: ChatRequest = {
323
+ ...base,
324
+ messages: [
325
+ ...base.messages,
326
+ { role: 'assistant', content: result.text },
327
+ { role: 'user', content: `That response did not match the required schema (${validated.message}). Reply again with ONLY a valid JSON value that matches it.` },
328
+ ],
329
+ };
330
+ result = await this.chat(conn, retry);
331
+ validated = await validateWithSchema(schema, result.text);
332
+ if (validated.ok) return { data: validated.value, result };
333
+ throw new AIError(`Model output failed schema validation: ${validated.message}`, { kind: 'invalid_response', provider: conn.provider });
334
+ }
335
+
336
+ /**
337
+ * Produce embeddings through the same governed pipeline as chat: policy check,
338
+ * key resolution, `beforeCall` hook, concurrency, and exactly one redacted
339
+ * telemetry record. Throws a typed `invalid_request` error for providers whose
340
+ * adapter has no `embed` (Anthropic, Google) rather than failing silently.
341
+ */
342
+ async embed(conn: Connection, req: EmbedRequest): Promise<EmbedResult> {
200
343
  const callId = createCallId();
201
344
  const startedAt = Date.now();
202
- const req: ChatRequest = { model: operation, messages: [], metadata: { operation } };
203
- const policyResult = this.policy.checkModel(conn.provider, operation);
204
- if (!policyResult.allowed) {
205
- const error = new AIError(policyResult.reason, { kind: 'policy_blocked', retryable: false, provider: conn.provider });
206
- await this.recordFailure(callId, conn, req, startedAt, 1, error);
345
+ const inputChars = (Array.isArray(req.input) ? req.input : [req.input]).reduce((sum, text) => sum + text.length, 0);
346
+ const fail = async (error: AIError): Promise<never> => {
347
+ await this.recordEmbed(callId, conn, req, startedAt, { estimated: true }, inputChars, error);
207
348
  throw this.redactedError(error);
349
+ };
350
+
351
+ const preflight = await this.preflight(callId, conn, req.model, req.metadata);
352
+ if (!preflight.ok) return fail(preflight.error);
353
+ const resolved = preflight.resolved;
354
+
355
+ const adapter = adapterFor(conn.provider, conn.baseUrl);
356
+ if (!adapter.embed) {
357
+ return fail(new AIError(`Provider ${conn.provider} does not support embeddings`, { kind: 'invalid_request', retryable: false, provider: conn.provider }));
208
358
  }
209
- let resolved: ResolvedConnection;
359
+
360
+ await this.acquire(req.signal);
210
361
  try {
211
- resolved = await this.resolveConnection(conn);
212
- const info: CallInfo = { callId, connection: conn, provider: conn.provider, model: operation, metadata: req.metadata };
213
- if (await this.opts.hooks?.beforeCall?.(info) === 'deny') {
214
- throw new AIError('Call denied by beforeCall hook', { kind: 'policy_blocked', retryable: false, provider: conn.provider });
215
- }
362
+ const result = await adapter.embed(resolved, req);
363
+ await this.recordEmbed(callId, conn, req, startedAt, result.usage, inputChars);
364
+ return result;
365
+ } catch (errorValue) {
366
+ return fail(fromUnknown(errorValue, conn.provider));
367
+ } finally {
368
+ this.release();
369
+ }
370
+ }
371
+
372
+ private async recordEmbed(callId: string, conn: Connection, req: EmbedRequest, startedAt: number, usage: Usage, inputChars: number, error?: AIError): Promise<void> {
373
+ const costUsd = error ? undefined : this.opts.pricing?.({ provider: conn.provider, model: req.model, usage });
374
+ await this.record({
375
+ callId,
376
+ connectionId: conn.id,
377
+ provider: conn.provider,
378
+ model: req.model,
379
+ startedAt,
380
+ timing: { firstTokenMs: null, totalMs: Date.now() - startedAt },
381
+ usage,
382
+ finishReason: error ? (error.kind === 'canceled' ? 'canceled' : 'error') : 'stop',
383
+ ...(error ? { error: { kind: error.kind, status: error.status, message: error.message } } : {}),
384
+ attempts: 1,
385
+ metadata: { ...req.metadata, operation: '__embed__' },
386
+ promptChars: inputChars,
387
+ responseChars: 0,
388
+ ...(costUsd !== undefined ? { costUsd } : {}),
389
+ });
390
+ }
391
+
392
+ private async runProbe<T>(conn: Connection, operation: '__listModels__' | '__testConnection__', action: (resolved: ResolvedConnection) => Promise<T>): Promise<T> {
393
+ const callId = createCallId();
394
+ const startedAt = Date.now();
395
+ const req: ChatRequest = { model: operation, messages: [], metadata: { operation } };
396
+ try {
397
+ const preflight = await this.preflight(callId, conn, operation, req.metadata);
398
+ if (!preflight.ok) throw preflight.error;
399
+ const resolved = preflight.resolved;
216
400
  await this.acquire(undefined);
217
401
  try {
218
402
  const value = await action(resolved);
@@ -309,6 +493,7 @@ export class AIHandler {
309
493
  }
310
494
 
311
495
  private async recordSuccess(callId: string, conn: Connection, req: ChatRequest, startedAt: number, attempts: number, result: ChatResult): Promise<void> {
496
+ const costUsd = this.opts.pricing?.({ provider: conn.provider, model: req.model, usage: result.usage });
312
497
  await this.record({
313
498
  callId,
314
499
  connectionId: conn.id,
@@ -322,6 +507,7 @@ export class AIHandler {
322
507
  metadata: req.metadata,
323
508
  promptChars: promptChars(req.messages),
324
509
  responseChars: result.text.length,
510
+ ...(costUsd !== undefined ? { costUsd } : {}),
325
511
  });
326
512
  }
327
513
 
@@ -408,6 +594,69 @@ function trimSlash(value: string): string {
408
594
  return value.replace(/\/+$/, '');
409
595
  }
410
596
 
597
+ /**
598
+ * Drops the resolved API key *and* neutralizes the auth headers built from it,
599
+ * so a `beforeCall` hook can inspect the endpoint but never the secret.
600
+ * `keyRef` is scrubbed too — a `{ kind: 'literal' }` ref carries the plaintext
601
+ * key, and hooks routinely log the whole object.
602
+ */
603
+ function safeResolved(resolved: ResolvedConnection): Omit<ResolvedConnection, 'apiKey'> {
604
+ const { apiKey: _apiKey, headers, ...rest } = resolved;
605
+ return { ...rest, ...safeKeyRef(resolved), headers: maskAuthHeaders(headers) };
606
+ }
607
+
608
+ /**
609
+ * The caller's own `Connection`, with any plaintext `keyRef` masked. Hooks get
610
+ * the connection so they can key off `id`/`provider`/`baseUrl`; they must never
611
+ * be handed the secret, since logging `info.connection` for audit is the
612
+ * obvious thing to do and would otherwise ship the key in clear text.
613
+ */
614
+ function safeConnection(conn: Connection): Connection {
615
+ return { ...conn, ...safeKeyRef(conn) };
616
+ }
617
+
618
+ function safeKeyRef(conn: Connection): { keyRef?: Connection['keyRef'] } {
619
+ if (conn.keyRef?.kind !== 'literal') return conn.keyRef === undefined ? {} : { keyRef: conn.keyRef };
620
+ return { keyRef: { kind: 'literal', value: REDACTED } };
621
+ }
622
+
623
+ const REDACTED = '[REDACTED]';
624
+
625
+ /** Header names whose value is derived from the API key by {@link applyAuth}. */
626
+ const AUTH_HEADERS = new Set(['authorization', 'x-api-key', 'x-goog-api-key', 'api-key']);
627
+
628
+ function maskAuthHeaders(headers: Record<string, string>): Record<string, string> {
629
+ const masked: Record<string, string> = {};
630
+ for (const [name, value] of Object.entries(headers)) {
631
+ masked[name] = AUTH_HEADERS.has(name.toLowerCase()) ? REDACTED : value;
632
+ }
633
+ return masked;
634
+ }
635
+
636
+ /**
637
+ * Attach a per-call `Idempotency-Key` for providers that honor one, so a retry
638
+ * after a dropped connection can't be billed as a second generation. Only set
639
+ * where the profile declares support, and never overrides a caller-supplied
640
+ * header.
641
+ */
642
+ function withIdempotencyKey(resolved: ResolvedConnection, callId: string): ResolvedConnection {
643
+ const profile = profileFor(resolved.provider, resolved.baseUrl);
644
+ if (!profile.quirks?.supportsIdempotencyKey) return resolved;
645
+ if (Object.keys(resolved.headers).some((name) => name.toLowerCase() === 'idempotency-key')) return resolved;
646
+ return { ...resolved, headers: { ...resolved.headers, 'idempotency-key': callId } };
647
+ }
648
+
649
+ async function validateWithSchema<T>(schema: StandardSchemaV1<T>, text: string): Promise<{ ok: true; value: T } | { ok: false; message: string }> {
650
+ const value = extractJson(text);
651
+ if (value === null) return { ok: false, message: 'no JSON found in model output' };
652
+ const result = await schema['~standard'].validate(value);
653
+ if (result.issues) {
654
+ const message = result.issues.map((issue) => issue.message).join('; ');
655
+ return { ok: false, message: message || 'validation failed' };
656
+ }
657
+ return { ok: true, value: result.value };
658
+ }
659
+
411
660
  function createCallId(): string {
412
661
  return globalThis.crypto?.randomUUID?.() ?? `call_${Date.now()}_${Math.random().toString(36).slice(2)}`;
413
662
  }
@@ -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,174 @@
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
+
25
+ const TAG_PAIRS: { open: string; close: string }[] = [
26
+ { open: '<think>', close: '</think>' },
27
+ { open: '<thinking>', close: '</thinking>' },
28
+ { open: '<reasoning>', close: '</reasoning>' },
29
+ { open: '<|begin_of_thought|>', close: '<|end_of_thought|>' },
30
+ ];
31
+
32
+ const LONGEST_TAG = Math.max(...TAG_PAIRS.flatMap((p) => [p.open.length, p.close.length]));
33
+
34
+ export interface ReasoningStripResult {
35
+ /** Text safe to show from this chunk (may be empty). */
36
+ visible: string;
37
+ /** Reasoning text removed from this chunk (may be empty). */
38
+ reasoning: string;
39
+ }
40
+
41
+ export interface ReasoningStripper {
42
+ /** Feed the next raw delta; returns what may be shown and what was reasoning. */
43
+ push(chunk: string): ReasoningStripResult;
44
+ /** End of stream: flushes any held-back prefix that never became a tag. */
45
+ end(): ReasoningStripResult;
46
+ /** True while inside an unterminated reasoning block. */
47
+ readonly inReasoning: boolean;
48
+ }
49
+
50
+ function partialTagSuffixLength(text: string): number {
51
+ const lower = text.toLowerCase();
52
+ const maxLen = Math.min(LONGEST_TAG - 1, lower.length);
53
+ for (let len = maxLen; len >= 1; len -= 1) {
54
+ const tail = lower.slice(lower.length - len);
55
+ if (tail[0] !== '<') continue;
56
+ for (const pair of TAG_PAIRS) {
57
+ if (pair.open.startsWith(tail) || pair.close.startsWith(tail)) return len;
58
+ }
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ function closesOwnLine(text: string, idx: number): boolean {
64
+ let i = idx - 1;
65
+ while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\r')) i -= 1;
66
+ return i < 0 || text[i] === '\n';
67
+ }
68
+
69
+ export function createReasoningStripper(): ReasoningStripper {
70
+ let pending = '';
71
+ let inReasoning = false;
72
+ let closeTag = '';
73
+ let emittedVisible = false;
74
+ let orphanConsumed = false;
75
+
76
+ function process(input: string, flushAll: boolean): ReasoningStripResult {
77
+ let text = pending + input;
78
+ pending = '';
79
+ let visible = '';
80
+ let reasoning = '';
81
+
82
+ while (text.length > 0) {
83
+ const lower = text.toLowerCase();
84
+ if (inReasoning) {
85
+ const idx = lower.indexOf(closeTag);
86
+ if (idx === -1) {
87
+ const hold = flushAll ? 0 : partialTagSuffixLength(text);
88
+ const consumed = text.length - hold;
89
+ reasoning += text.slice(0, consumed);
90
+ pending = text.slice(consumed);
91
+ text = '';
92
+ if (flushAll) {
93
+ reasoning += pending;
94
+ pending = '';
95
+ }
96
+ break;
97
+ }
98
+ reasoning += text.slice(0, idx);
99
+ text = text.slice(idx + closeTag.length);
100
+ inReasoning = false;
101
+ closeTag = '';
102
+ if (text.startsWith('\n')) text = text.slice(1);
103
+ continue;
104
+ }
105
+ let openIdx = -1;
106
+ let pair: { open: string; close: string } | null = null;
107
+ for (const p of TAG_PAIRS) {
108
+ const i = lower.indexOf(p.open);
109
+ if (i !== -1 && (openIdx === -1 || i < openIdx)) {
110
+ openIdx = i;
111
+ pair = p;
112
+ }
113
+ }
114
+ let orphanCloseIdx = -1;
115
+ let orphanCloseLen = 0;
116
+ if (!emittedVisible && !orphanConsumed) {
117
+ for (const p of TAG_PAIRS) {
118
+ const i = lower.indexOf(p.close);
119
+ if (i !== -1 && (orphanCloseIdx === -1 || i < orphanCloseIdx) && closesOwnLine(text, i)) {
120
+ orphanCloseIdx = i;
121
+ orphanCloseLen = p.close.length;
122
+ }
123
+ }
124
+ }
125
+ if (orphanCloseIdx !== -1 && (openIdx === -1 || orphanCloseIdx < openIdx)) {
126
+ orphanConsumed = true;
127
+ reasoning += text.slice(0, orphanCloseIdx);
128
+ text = text.slice(orphanCloseIdx + orphanCloseLen).replace(/^\n+/, '');
129
+ continue;
130
+ }
131
+ if (openIdx === -1 || !pair) {
132
+ const hold = flushAll ? 0 : partialTagSuffixLength(text);
133
+ visible += text.slice(0, text.length - hold);
134
+ pending = text.slice(text.length - hold);
135
+ text = '';
136
+ break;
137
+ }
138
+ visible += text.slice(0, openIdx);
139
+ text = text.slice(openIdx + pair.open.length);
140
+ inReasoning = true;
141
+ closeTag = pair.close;
142
+ }
143
+ if (visible.trim().length > 0) emittedVisible = true;
144
+ return { visible, reasoning };
145
+ }
146
+
147
+ return {
148
+ push: (chunk) => process(chunk, false),
149
+ end: () => process('', true),
150
+ get inReasoning() {
151
+ return inReasoning;
152
+ },
153
+ };
154
+ }
155
+
156
+ /** Whole-string form: `{ visible, reasoning }` for already-buffered text. */
157
+ export function stripReasoningBlocksDetailed(text: string): ReasoningStripResult {
158
+ if (!text) return { visible: '', reasoning: '' };
159
+ const stripper = createReasoningStripper();
160
+ const a = stripper.push(text);
161
+ const b = stripper.end();
162
+ return { visible: (a.visible + b.visible).trim(), reasoning: a.reasoning + b.reasoning };
163
+ }
164
+
165
+ /** Whole-string form returning only the visible text. */
166
+ export function stripReasoningBlocks(text: string): string {
167
+ return stripReasoningBlocksDetailed(text).visible;
168
+ }
169
+
170
+ /** Cheap pre-check: does `text` carry any inline reasoning tag (open or close)? */
171
+ export function containsReasoningBlock(text: string): boolean {
172
+ const lower = text.toLowerCase();
173
+ return TAG_PAIRS.some((p) => lower.includes(p.open) || lower.includes(p.close));
174
+ }