@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 +119 -3
- package/dist/adapters/engines/google.d.ts +10 -0
- package/dist/adapters/engines/google.js +22 -2
- package/dist/agent/loop.js +4 -1
- package/dist/connect.d.ts +31 -0
- package/dist/connect.js +25 -0
- package/dist/handler.js +8 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/keys.js +1 -4
- package/dist/util.d.ts +1 -0
- package/dist/util.js +4 -0
- package/nugget/VERSION.txt +2 -2
- package/nugget/src/adapters/engines/google.ts +22 -2
- package/nugget/src/agent/loop.ts +5 -1
- package/nugget/src/connect.ts +48 -0
- package/nugget/src/handler.ts +7 -2
- package/nugget/src/index.ts +1 -0
- package/nugget/src/keys.ts +1 -5
- package/nugget/src/util.ts +5 -0
- package/package.json +3 -2
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
|
|
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
|
|
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');
|
package/dist/agent/loop.js
CHANGED
|
@@ -216,7 +216,7 @@ function callsFromPromptJson(text) {
|
|
|
216
216
|
if (typeof record.tool !== 'string')
|
|
217
217
|
continue;
|
|
218
218
|
calls.push({
|
|
219
|
-
id:
|
|
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
|
package/dist/connect.js
ADDED
|
@@ -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
|
|
245
|
-
|
|
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
package/dist/index.js
CHANGED
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();
|
package/nugget/VERSION.txt
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
0.
|
|
2
|
-
|
|
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> {
|
package/nugget/src/agent/loop.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/nugget/src/handler.ts
CHANGED
|
@@ -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
|
|
278
|
-
|
|
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
|
}
|
package/nugget/src/index.ts
CHANGED
package/nugget/src/keys.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { KeyRef, KeySource } from './types.js';
|
|
2
|
+
import { globalEnv } from './util.js';
|
|
2
3
|
|
|
3
4
|
export function parseKeyRef(value?: string | null): KeyRef {
|
|
4
5
|
const trimmed = value?.trim();
|
|
@@ -63,8 +64,3 @@ export function chainKeySources(...sources: KeySource[]): KeySource {
|
|
|
63
64
|
},
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
-
function globalEnv(): Record<string, string | undefined> {
|
|
68
|
-
const maybeProcess = globalThis as typeof globalThis & { process?: { env?: Record<string, string | undefined> } };
|
|
69
|
-
return maybeProcess.process?.env ?? {};
|
|
70
|
-
}
|
package/nugget/src/util.ts
CHANGED
|
@@ -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
|
+
"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
|
+
"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": {
|