@jxburros/ai-nugget 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ talking to AI model providers through one pipeline:
7
7
  policy → key resolution → beforeCall hook → concurrency/retry → provider adapter → redacted telemetry
8
8
  ```
9
9
 
10
- It runs identically in Node ≥ 20, the browser main thread, and Web Workers using
10
+ It runs identically in Node 20.19+/22.12+, the browser main thread, and Web Workers using
11
11
  only `fetch`, `ReadableStream`, `AbortController`, and `TextDecoder`. It is
12
12
  intentionally **not** a router, prompt library, memory system, or secrets vault:
13
13
  apps choose models, own prompts, store secrets, and decide what policy to enforce.
@@ -58,6 +58,13 @@ with a `baseUrl` resolve to the `openai-compat` escape hatch. **Grok/xAI is not
58
58
  blocked and is never officially integrated** — point `openai-compat` at any
59
59
  endpoint if you must; that is your configuration, not a supported profile.
60
60
 
61
+ **Wire URL for `openai-compat` and the other local runtimes.** `openai-compat`,
62
+ `lmstudio`, `llamacpp`, and `vllm` have no `urlTemplate` in the profile table,
63
+ so the `openaiChat` engine falls back to `POST {baseUrl}/chat/completions`
64
+ (no `/v1` prefix — include it in `baseUrl` yourself if your server needs it,
65
+ e.g. `http://localhost:8080/v1`). This matters most when standing up a local
66
+ mock or test server: it has to listen on that exact path.
67
+
61
68
  Model identity is always `(source, model)` — `modelRef(source, model)` gives the
62
69
  canonical `provider/model` key so the same weights served by different hosts stay
63
70
  distinct and comparable.
@@ -139,6 +146,96 @@ AI Nugget handler:
139
146
  See `examples/model-picker.mjs` for a minimal runnable reference implementing
140
147
  this pattern.
141
148
 
149
+ ## Recipes
150
+
151
+ Small server apps built directly on `AIHandler` (see
152
+ `examples/npm-mini-apps/`) tend to hit the same three points of friction:
153
+ resolving connection config, turning a JSON-mode reply into a validated app
154
+ object, and mapping a failure into an HTTP response. None of this is policy —
155
+ it's just the boilerplate every consumer re-derives — so here's the
156
+ canonical shape for each.
157
+
158
+ ### Env-based connection setup
159
+
160
+ `envConnection()` resolves a `Connection` plus a default `model` from
161
+ app-owned env vars (`AI_PROVIDER`, `AI_MODEL`, `AI_KEY_ENV`, `AI_BASE_URL` by
162
+ default):
163
+
164
+ ```ts
165
+ import { AIHandler, envConnection, envKeySource } from '@jxburros/ai-nugget';
166
+
167
+ const handler = new AIHandler({ keySource: envKeySource() });
168
+ const { connection, model } = envConnection({ id: 'my-app', defaultProvider: 'openai', defaultModel: 'gpt-4o-mini' });
169
+
170
+ const result = await handler.chat(connection, { model, messages: [{ role: 'user', content: 'Hello!' }] });
171
+ ```
172
+
173
+ It only reads the server's own environment — `provider`/`baseUrl` never come
174
+ from client input this way either, same as the manual pattern it replaces.
175
+ Pass `env`, `providerVar`/`modelVar`/`keyEnvVar`/`baseUrlVar`, or
176
+ `defaultProvider`/`defaultModel`/`defaultKeyEnv` to override the var names or
177
+ fallbacks an app already uses.
178
+
179
+ ### JSON output + validation
180
+
181
+ Ask for JSON in the prompt (and set `responseFormat: { type: 'json' }` for
182
+ providers with `capabilities.jsonMode`), then run the raw text through
183
+ `extractJsonWithSchema` with a small parse function built from the
184
+ `require*` guards in `json.ts` — don't hand-roll a `/\{[\s\S]*\}/` regex plus
185
+ `JSON.parse`, which silently accepts the first brace-looking substring and
186
+ gives no useful error on a malformed or schema-incomplete reply:
187
+
188
+ ```ts
189
+ import { extractJsonWithSchema, requireNumber, requireString } from '@jxburros/ai-nugget';
190
+
191
+ function parseSprint(raw: unknown) {
192
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Expected a JSON object');
193
+ const record = raw as Record<string, unknown>;
194
+ return { headline: requireString(record, 'headline'), minutes: requireNumber(record, 'minutes') };
195
+ }
196
+
197
+ const sprint = extractJsonWithSchema(result.text, parseSprint);
198
+ ```
199
+
200
+ `extractJsonWithSchema` recovers JSON from fenced code blocks or prose the
201
+ model wrapped around it, and throws a typed `AIError` with
202
+ `kind: 'invalid_response'` on anything that doesn't parse or doesn't match
203
+ the schema — no JSON found, an array where an object was expected, a missing
204
+ field, a string where a number belongs. That error flows into the same
205
+ `AIError`-kind switch as any other provider failure (see below), so JSON
206
+ validation doesn't need its own error path. `require*` covers scalars and
207
+ string arrays only — validate array length, numeric ranges, or nested shapes
208
+ yourself, same as `parseSprint` above; this is deliberately not a full
209
+ schema validator (see Non-goals).
210
+
211
+ ### Error handling matrix
212
+
213
+ Every failure that reaches your `catch` around `handler.chat()`/`stream()` —
214
+ including a JSON-validation failure from `extractJsonWithSchema` — is (or is
215
+ normalized to) an `AIError` with a `kind`. Map `kind` to an HTTP status and a
216
+ user-facing message once, log the raw `error.message` server-side, and never
217
+ forward it to the client:
218
+
219
+ | `kind` | Suggested status | Example user-facing message |
220
+ |---|---|---|
221
+ | `invalid_request` | 400 | "That request was invalid." |
222
+ | `context_length` | 413 | "Your input is too long. Please shorten it and try again." |
223
+ | `auth` | 502 | "The AI service is not configured correctly." |
224
+ | `key_unavailable` | 500 | "The AI service is not configured correctly." |
225
+ | `policy_blocked` | 403 | "This request was blocked by policy." |
226
+ | `rate_limit` | 429 | "The AI service is busy. Please try again shortly." |
227
+ | `timeout` | 504 | "The request took too long. Please try again." |
228
+ | `canceled` | 499 | "The request was canceled." |
229
+ | `invalid_response` | 502 | "The AI response wasn't in the expected format. Please try again." |
230
+ | `network` / `server` | 502 | "The AI service is temporarily unavailable." |
231
+ | `tool_error` | 500 | "Something went wrong running a tool." |
232
+ | `budget_exceeded` | 429 | "This request used too many steps/tokens. Please try again." |
233
+
234
+ `auth` and `key_unavailable` map to 5xx, not 401/403, because it's the
235
+ *server's* credential that's wrong, not the caller's — the distinction
236
+ matters for who should act on the error. `examples/npm-mini-apps/*/ai-error-map.mjs`
237
+ is a copyable implementation of this table.
238
+
142
239
  ## What's implemented
143
240
 
144
241
  - **Core:** message-based contracts (`types.ts`), typed `AIError` + `classify()`,
@@ -170,7 +267,10 @@ this pattern.
170
267
  `allowlistPolicy` fails closed for providers omitted from the configured map;
171
268
  use `'*'` in a provider's prefix list to allow non-chat operation IDs.
172
269
  The default redactor covers common provider token formats and generic bearer
173
- tokens, with session-resolved keys always added exactly.
270
+ tokens, with session-resolved keys always added exactly. `envConnection()`
271
+ (`connect.ts`) resolves a `Connection` + default `model` from app-owned env
272
+ vars — config-resolution convenience only, not a new seam or a policy
273
+ default (see Recipes above).
174
274
  - **Agent layer (`@jxburros/ai-nugget/agent`):** `defineTool` + light JSON-schema
175
275
  arg validation (object-ness, `required`, top-level `properties[key].type` —
176
276
  not full JSON Schema: no `enum`, nested schemas, `oneOf`, bounds, `pattern`,
@@ -198,11 +298,16 @@ import { runAgent, defineTool } from '@jxburros/ai-nugget/agent';
198
298
  ```bash
199
299
  npm install
200
300
  npm test # Vitest contract suite in Node (97 tests; live tests skipped unless env-gated)
301
+ npx playwright install chromium # one-time, before test:browser
201
302
  npm run test:browser # same suite in headless Chromium (proves isomorphism)
202
303
  npm run build # tsc → dist/ (also the typecheck)
203
304
  npm run build:nugget # writes a vendorable nugget/ stamped with version + content hash
204
305
  ```
205
306
 
307
+ Requires Node `^20.19.0` or `>=22.12.0` (see `engines` in `package.json` and
308
+ `.nvmrc`) — the toolchain's build step (`vitest` → `rolldown`) needs a native
309
+ binding unavailable on older Node 20 patch releases.
310
+
206
311
  ### Live smoke tests (optional, env-gated)
207
312
 
208
313
  `tests/live-smoke.test.ts` exercises the real wire path against a live provider.
@@ -293,9 +398,20 @@ bundler hits this, vendor `dist/` (real `.js` + `.d.ts` pairs) instead of
293
398
  ## Examples
294
399
 
295
400
  `examples/` has runnable scripts against local Ollama and llama.cpp servers,
296
- `promptJson` vs. native tool-calling, an `ApprovalGate`, and telemetry — see
401
+ `promptJson` vs. native tool-calling, an `ApprovalGate`, telemetry, and a small
402
+ industrial-themed chatbot demo under `examples/steel-chat/` — see
297
403
  `examples/README.md`.
298
404
 
405
+ If you want a compact end-to-end app example instead of a single script, run:
406
+
407
+ ```bash
408
+ npm run demo:steel-chat
409
+ ```
410
+
411
+ That demo exposes a simple browser chat UI backed by `AIHandler`, discovers
412
+ allowed connections/models server-side, and displays the current package
413
+ version in the interface so the example stays aligned with the installed nugget.
414
+
299
415
  ## Agent skills
300
416
 
301
417
  `.claude/skills/` contains Agent Skills — task-specific guides that Claude Code
@@ -4,5 +4,15 @@ export declare class GoogleAdapter implements ProviderAdapter {
4
4
  constructor(provider: string);
5
5
  chat(conn: ResolvedConnection, req: ChatRequest): Promise<ChatResult>;
6
6
  stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent>;
7
+ /**
8
+ * `GoogleAdapter` has no `listModels`, so without a `health` probe
9
+ * `AIHandler.testConnection` would report `ok: true` without ever making a
10
+ * network call. `/v1beta/models` is a lightweight, key-authenticated GET
11
+ * that gives an honest connectivity/auth check.
12
+ */
13
+ health(conn: ResolvedConnection): Promise<{
14
+ ok: boolean;
15
+ detail?: string;
16
+ }>;
7
17
  }
8
18
  //# sourceMappingURL=google.d.ts.map
@@ -1,8 +1,8 @@
1
1
  import { AIError } from '../../errors.js';
2
2
  import { estimatedUsage } from '../../tokens.js';
3
- import { postResponse, sseLines } from '../../transport.js';
3
+ import { fetchJson, postResponse, sseLines } from '../../transport.js';
4
4
  import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
5
- import { streamError, streamTimeout } from './base.js';
5
+ import { DEFAULT_TIMEOUT_MS, streamError, streamTimeout } from './base.js';
6
6
  export class GoogleAdapter {
7
7
  provider;
8
8
  constructor(provider) {
@@ -84,6 +84,26 @@ export class GoogleAdapter {
84
84
  timeout.done();
85
85
  }
86
86
  }
87
+ /**
88
+ * `GoogleAdapter` has no `listModels`, so without a `health` probe
89
+ * `AIHandler.testConnection` would report `ok: true` without ever making a
90
+ * network call. `/v1beta/models` is a lightweight, key-authenticated GET
91
+ * that gives an honest connectivity/auth check.
92
+ */
93
+ async health(conn) {
94
+ try {
95
+ await fetchJson(`${conn.baseUrl}/v1beta/models`, {
96
+ method: 'GET',
97
+ headers: conn.headers,
98
+ timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 10_000),
99
+ provider: conn.provider,
100
+ });
101
+ return { ok: true };
102
+ }
103
+ catch (error) {
104
+ return { ok: false, detail: error instanceof Error ? error.message : 'Health check failed' };
105
+ }
106
+ }
87
107
  }
88
108
  function body(req) {
89
109
  const systemText = req.messages.filter((m) => m.role === 'system').map((m) => textContent(m.content)).join('\n\n');
@@ -216,7 +216,7 @@ function callsFromPromptJson(text) {
216
216
  if (typeof record.tool !== 'string')
217
217
  continue;
218
218
  calls.push({
219
- id: crypto.randomUUID(),
219
+ id: randomId(),
220
220
  name: record.tool,
221
221
  arguments: record.input && typeof record.input === 'object' ? record.input : {},
222
222
  raw: JSON.stringify(record.input ?? {}),
@@ -224,6 +224,9 @@ function callsFromPromptJson(text) {
224
224
  }
225
225
  return calls;
226
226
  }
227
+ function randomId() {
228
+ return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
229
+ }
227
230
  function textContent(content) {
228
231
  if (typeof content === 'string')
229
232
  return content;
@@ -0,0 +1,31 @@
1
+ import type { Connection } from './types.js';
2
+ export interface EnvConnectionOptions {
3
+ /** Read from this env-like object instead of the process/global environment. */
4
+ env?: Record<string, string | undefined>;
5
+ /** Connection id. Defaults to `'app'`. */
6
+ id?: string;
7
+ /** Env var names to read (override if an app already uses different names). */
8
+ providerVar?: string;
9
+ modelVar?: string;
10
+ keyEnvVar?: string;
11
+ baseUrlVar?: string;
12
+ /** Fallbacks used when the corresponding env var is unset. */
13
+ defaultProvider?: string;
14
+ defaultModel?: string;
15
+ defaultKeyEnv?: string;
16
+ }
17
+ export interface EnvConnection {
18
+ connection: Connection;
19
+ model: string;
20
+ }
21
+ /**
22
+ * Resolves a server-side `Connection` plus a default `model` from app-owned
23
+ * env vars (`AI_PROVIDER`, `AI_MODEL`, `AI_KEY_ENV`, `AI_BASE_URL` unless
24
+ * overridden). This only collapses the config-reading boilerplate that was
25
+ * being hand-rolled the same way in every small server app; it does not add
26
+ * policy. `provider`/`baseUrl` still come from the server's own environment,
27
+ * never from client input, and the returned `keyRef` still resolves through
28
+ * `KeySource` at call time like any other `Connection`.
29
+ */
30
+ export declare function envConnection(options?: EnvConnectionOptions): EnvConnection;
31
+ //# sourceMappingURL=connect.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { globalEnv } from './util.js';
2
+ /**
3
+ * Resolves a server-side `Connection` plus a default `model` from app-owned
4
+ * env vars (`AI_PROVIDER`, `AI_MODEL`, `AI_KEY_ENV`, `AI_BASE_URL` unless
5
+ * overridden). This only collapses the config-reading boilerplate that was
6
+ * being hand-rolled the same way in every small server app; it does not add
7
+ * policy. `provider`/`baseUrl` still come from the server's own environment,
8
+ * never from client input, and the returned `keyRef` still resolves through
9
+ * `KeySource` at call time like any other `Connection`.
10
+ */
11
+ export function envConnection(options = {}) {
12
+ const env = options.env ?? globalEnv();
13
+ const provider = env[options.providerVar ?? 'AI_PROVIDER']?.trim() || options.defaultProvider || 'openai';
14
+ const model = env[options.modelVar ?? 'AI_MODEL']?.trim() || options.defaultModel || 'gpt-4o-mini';
15
+ const keyEnvName = env[options.keyEnvVar ?? 'AI_KEY_ENV']?.trim() || options.defaultKeyEnv || 'OPENAI_API_KEY';
16
+ const baseUrl = env[options.baseUrlVar ?? 'AI_BASE_URL']?.trim();
17
+ const connection = {
18
+ id: options.id ?? 'app',
19
+ provider,
20
+ keyRef: { kind: 'env', name: keyEnvName },
21
+ ...(baseUrl ? { baseUrl } : {}),
22
+ };
23
+ return { connection, model };
24
+ }
25
+ //# sourceMappingURL=connect.js.map
package/dist/handler.js CHANGED
@@ -241,14 +241,20 @@ export class AIHandler {
241
241
  }
242
242
  this.active += 1;
243
243
  const minInterval = this.opts.limits?.minIntervalMs ?? 0;
244
- const wait = Math.max(0, this.lastStarted + minInterval - Date.now());
245
- this.lastStarted = Date.now() + wait;
244
+ const previousLastStarted = this.lastStarted;
245
+ const wait = Math.max(0, previousLastStarted + minInterval - Date.now());
246
+ const reserved = Date.now() + wait;
247
+ this.lastStarted = reserved;
246
248
  try {
247
249
  if (wait)
248
250
  await sleep(wait, signal);
249
251
  }
250
252
  catch (error) {
251
253
  this.release();
254
+ // Only undo our own reservation — a later caller may have already
255
+ // paced itself off `reserved` and must keep that spacing.
256
+ if (this.lastStarted === reserved)
257
+ this.lastStarted = previousLastStarted;
252
258
  throw error;
253
259
  }
254
260
  }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './transport.js';
4
4
  export * from './json.js';
5
5
  export * from './tokens.js';
6
6
  export * from './keys.js';
7
+ export * from './connect.js';
7
8
  export * from './redact.js';
8
9
  export * from './policy.js';
9
10
  export * from './handler.js';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from './transport.js';
4
4
  export * from './json.js';
5
5
  export * from './tokens.js';
6
6
  export * from './keys.js';
7
+ export * from './connect.js';
7
8
  export * from './redact.js';
8
9
  export * from './policy.js';
9
10
  export * from './handler.js';
package/dist/keys.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { globalEnv } from './util.js';
1
2
  export function parseKeyRef(value) {
2
3
  const trimmed = value?.trim();
3
4
  if (!trimmed)
@@ -71,8 +72,4 @@ export function chainKeySources(...sources) {
71
72
  },
72
73
  };
73
74
  }
74
- function globalEnv() {
75
- const maybeProcess = globalThis;
76
- return maybeProcess.process?.env ?? {};
77
- }
78
75
  //# sourceMappingURL=keys.js.map
package/dist/util.d.ts CHANGED
@@ -4,5 +4,6 @@ export declare function asString(value: unknown): string | undefined;
4
4
  export declare function asNumber(value: unknown): number | undefined;
5
5
  export declare function promptChars(messages: ChatMessage[]): number;
6
6
  export declare function textFromMessages(messages: ChatMessage[]): string;
7
+ export declare function globalEnv(): Record<string, string | undefined>;
7
8
  export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
8
9
  //# sourceMappingURL=util.d.ts.map
package/dist/util.js CHANGED
@@ -21,6 +21,10 @@ export function textFromMessages(messages) {
21
21
  return message.content.map((part) => part.text ?? '').join('');
22
22
  }).join('\n');
23
23
  }
24
+ export function globalEnv() {
25
+ const maybeProcess = globalThis;
26
+ return maybeProcess.process?.env ?? {};
27
+ }
24
28
  export function sleep(ms, signal) {
25
29
  if (ms <= 0)
26
30
  return Promise.resolve();
@@ -1,2 +1,2 @@
1
- 0.3.1
2
- a15d2e448ed391c96856ccb9b4f423d229a7bfd684bf394a5c3d93a5fcd876f6
1
+ 0.4.1
2
+ 4dec12bfcf23c8282cd488a90048ea2a96247d16b84140fad709a3b976ce71e9
@@ -1,9 +1,9 @@
1
1
  import { AIError } from '../../errors.js';
2
2
  import { estimatedUsage } from '../../tokens.js';
3
- import { postResponse, sseLines } from '../../transport.js';
3
+ import { fetchJson, postResponse, sseLines } from '../../transport.js';
4
4
  import type { ChatMessage, ChatRequest, ChatResult, ProviderAdapter, ResolvedConnection, StreamEvent, ToolCall } from '../../types.js';
5
5
  import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
6
- import { streamError, streamTimeout } from './base.js';
6
+ import { DEFAULT_TIMEOUT_MS, streamError, streamTimeout } from './base.js';
7
7
 
8
8
  export class GoogleAdapter implements ProviderAdapter {
9
9
  readonly provider: string;
@@ -81,6 +81,26 @@ export class GoogleAdapter implements ProviderAdapter {
81
81
  timeout.done();
82
82
  }
83
83
  }
84
+
85
+ /**
86
+ * `GoogleAdapter` has no `listModels`, so without a `health` probe
87
+ * `AIHandler.testConnection` would report `ok: true` without ever making a
88
+ * network call. `/v1beta/models` is a lightweight, key-authenticated GET
89
+ * that gives an honest connectivity/auth check.
90
+ */
91
+ async health(conn: ResolvedConnection): Promise<{ ok: boolean; detail?: string }> {
92
+ try {
93
+ await fetchJson(`${conn.baseUrl}/v1beta/models`, {
94
+ method: 'GET',
95
+ headers: conn.headers,
96
+ timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 10_000),
97
+ provider: conn.provider,
98
+ });
99
+ return { ok: true };
100
+ } catch (error) {
101
+ return { ok: false, detail: error instanceof Error ? error.message : 'Health check failed' };
102
+ }
103
+ }
84
104
  }
85
105
 
86
106
  function body(req: ChatRequest): Record<string, unknown> {
@@ -268,7 +268,7 @@ function callsFromPromptJson(text: string): ToolCall[] {
268
268
  const record = entry as Record<string, unknown>;
269
269
  if (typeof record.tool !== 'string') continue;
270
270
  calls.push({
271
- id: crypto.randomUUID(),
271
+ id: randomId(),
272
272
  name: record.tool,
273
273
  arguments: record.input && typeof record.input === 'object' ? record.input : {},
274
274
  raw: JSON.stringify(record.input ?? {}),
@@ -277,6 +277,10 @@ function callsFromPromptJson(text: string): ToolCall[] {
277
277
  return calls;
278
278
  }
279
279
 
280
+ function randomId(): string {
281
+ return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
282
+ }
283
+
280
284
  function textContent(content: ChatMessage['content']): string {
281
285
  if (typeof content === 'string') return content;
282
286
  if (!content) return '';
@@ -0,0 +1,48 @@
1
+ import type { Connection } from './types.js';
2
+ import { globalEnv } from './util.js';
3
+
4
+ export interface EnvConnectionOptions {
5
+ /** Read from this env-like object instead of the process/global environment. */
6
+ env?: Record<string, string | undefined>;
7
+ /** Connection id. Defaults to `'app'`. */
8
+ id?: string;
9
+ /** Env var names to read (override if an app already uses different names). */
10
+ providerVar?: string;
11
+ modelVar?: string;
12
+ keyEnvVar?: string;
13
+ baseUrlVar?: string;
14
+ /** Fallbacks used when the corresponding env var is unset. */
15
+ defaultProvider?: string;
16
+ defaultModel?: string;
17
+ defaultKeyEnv?: string;
18
+ }
19
+
20
+ export interface EnvConnection {
21
+ connection: Connection;
22
+ model: string;
23
+ }
24
+
25
+ /**
26
+ * Resolves a server-side `Connection` plus a default `model` from app-owned
27
+ * env vars (`AI_PROVIDER`, `AI_MODEL`, `AI_KEY_ENV`, `AI_BASE_URL` unless
28
+ * overridden). This only collapses the config-reading boilerplate that was
29
+ * being hand-rolled the same way in every small server app; it does not add
30
+ * policy. `provider`/`baseUrl` still come from the server's own environment,
31
+ * never from client input, and the returned `keyRef` still resolves through
32
+ * `KeySource` at call time like any other `Connection`.
33
+ */
34
+ export function envConnection(options: EnvConnectionOptions = {}): EnvConnection {
35
+ const env = options.env ?? globalEnv();
36
+ const provider = env[options.providerVar ?? 'AI_PROVIDER']?.trim() || options.defaultProvider || 'openai';
37
+ const model = env[options.modelVar ?? 'AI_MODEL']?.trim() || options.defaultModel || 'gpt-4o-mini';
38
+ const keyEnvName = env[options.keyEnvVar ?? 'AI_KEY_ENV']?.trim() || options.defaultKeyEnv || 'OPENAI_API_KEY';
39
+ const baseUrl = env[options.baseUrlVar ?? 'AI_BASE_URL']?.trim();
40
+
41
+ const connection: Connection = {
42
+ id: options.id ?? 'app',
43
+ provider,
44
+ keyRef: { kind: 'env', name: keyEnvName },
45
+ ...(baseUrl ? { baseUrl } : {}),
46
+ };
47
+ return { connection, model };
48
+ }
@@ -274,12 +274,17 @@ export class AIHandler {
274
274
  }
275
275
  this.active += 1;
276
276
  const minInterval = this.opts.limits?.minIntervalMs ?? 0;
277
- const wait = Math.max(0, this.lastStarted + minInterval - Date.now());
278
- this.lastStarted = Date.now() + wait;
277
+ const previousLastStarted = this.lastStarted;
278
+ const wait = Math.max(0, previousLastStarted + minInterval - Date.now());
279
+ const reserved = Date.now() + wait;
280
+ this.lastStarted = reserved;
279
281
  try {
280
282
  if (wait) await sleep(wait, signal);
281
283
  } catch (error) {
282
284
  this.release();
285
+ // Only undo our own reservation — a later caller may have already
286
+ // paced itself off `reserved` and must keep that spacing.
287
+ if (this.lastStarted === reserved) this.lastStarted = previousLastStarted;
283
288
  throw error;
284
289
  }
285
290
  }
@@ -4,6 +4,7 @@ export * from './transport.js';
4
4
  export * from './json.js';
5
5
  export * from './tokens.js';
6
6
  export * from './keys.js';
7
+ export * from './connect.js';
7
8
  export * from './redact.js';
8
9
  export * from './policy.js';
9
10
  export * from './handler.js';
@@ -1,4 +1,5 @@
1
1
  import type { KeyRef, KeySource } from './types.js';
2
+ import { globalEnv } from './util.js';
2
3
 
3
4
  export function parseKeyRef(value?: string | null): KeyRef {
4
5
  const trimmed = value?.trim();
@@ -63,8 +64,3 @@ export function chainKeySources(...sources: KeySource[]): KeySource {
63
64
  },
64
65
  };
65
66
  }
66
-
67
- function globalEnv(): Record<string, string | undefined> {
68
- const maybeProcess = globalThis as typeof globalThis & { process?: { env?: Record<string, string | undefined> } };
69
- return maybeProcess.process?.env ?? {};
70
- }
@@ -26,6 +26,11 @@ export function textFromMessages(messages: ChatMessage[]): string {
26
26
  }).join('\n');
27
27
  }
28
28
 
29
+ export function globalEnv(): Record<string, string | undefined> {
30
+ const maybeProcess = globalThis as typeof globalThis & { process?: { env?: Record<string, string | undefined> } };
31
+ return maybeProcess.process?.env ?? {};
32
+ }
33
+
29
34
  export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
30
35
  if (ms <= 0) return Promise.resolve();
31
36
  return new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxburros/ai-nugget",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Small isomorphic AI model nugget for provider calls, policy, redaction, telemetry, and agent loops.",
@@ -17,7 +17,7 @@
17
17
  "telemetry"
18
18
  ],
19
19
  "engines": {
20
- "node": ">=20"
20
+ "node": "^20.19.0 || >=22.12.0"
21
21
  },
22
22
  "main": "./dist/index.js",
23
23
  "types": "./dist/index.d.ts",
@@ -56,6 +56,7 @@
56
56
  "test:live": "vitest run tests/live-smoke.test.ts",
57
57
  "typecheck": "tsc -p tsconfig.json --noEmit",
58
58
  "build:nugget": "node scripts/build-nugget.mjs",
59
+ "demo:steel-chat": "npm run build && node examples/steel-chat/server.mjs",
59
60
  "prepublishOnly": "npm run build && npm run build:nugget"
60
61
  },
61
62
  "devDependencies": {