@jxburros/ai-nugget 0.3.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/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/adapters/engines/anthropic.d.ts +8 -0
- package/dist/adapters/engines/anthropic.js +278 -0
- package/dist/adapters/engines/base.d.ts +23 -0
- package/dist/adapters/engines/base.js +93 -0
- package/dist/adapters/engines/google.d.ts +8 -0
- package/dist/adapters/engines/google.js +217 -0
- package/dist/adapters/engines/ollama.d.ts +10 -0
- package/dist/adapters/engines/ollama.js +160 -0
- package/dist/adapters/engines/openaiChat.d.ts +15 -0
- package/dist/adapters/engines/openaiChat.js +228 -0
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/profiles.d.ts +42 -0
- package/dist/adapters/profiles.js +111 -0
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.js +3 -0
- package/dist/agent/loop.d.ts +70 -0
- package/dist/agent/loop.js +263 -0
- package/dist/agent/tools.d.ts +29 -0
- package/dist/agent/tools.js +48 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +80 -0
- package/dist/handler.d.ts +48 -0
- package/dist/handler.js +371 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json.d.ts +7 -0
- package/dist/json.js +114 -0
- package/dist/keys.d.ts +7 -0
- package/dist/keys.js +78 -0
- package/dist/policy.d.ts +6 -0
- package/dist/policy.js +37 -0
- package/dist/redact.d.ts +8 -0
- package/dist/redact.js +46 -0
- package/dist/tokens.d.ts +5 -0
- package/dist/tokens.js +18 -0
- package/dist/transport.d.ts +28 -0
- package/dist/transport.js +145 -0
- package/dist/types.d.ts +191 -0
- package/dist/types.js +4 -0
- package/dist/util.d.ts +8 -0
- package/dist/util.js +51 -0
- package/nugget/VERSION.txt +2 -0
- package/nugget/src/adapters/engines/anthropic.ts +260 -0
- package/nugget/src/adapters/engines/base.ts +90 -0
- package/nugget/src/adapters/engines/google.ts +209 -0
- package/nugget/src/adapters/engines/ollama.ts +153 -0
- package/nugget/src/adapters/engines/openaiChat.ts +223 -0
- package/nugget/src/adapters/index.ts +17 -0
- package/nugget/src/adapters/profiles.ts +149 -0
- package/nugget/src/agent/index.ts +2 -0
- package/nugget/src/agent/loop.ts +314 -0
- package/nugget/src/agent/tools.ts +55 -0
- package/nugget/src/errors.ts +80 -0
- package/nugget/src/handler.ts +408 -0
- package/nugget/src/index.ts +10 -0
- package/nugget/src/json.ts +108 -0
- package/nugget/src/keys.ts +70 -0
- package/nugget/src/policy.ts +39 -0
- package/nugget/src/redact.ts +48 -0
- package/nugget/src/tokens.ts +21 -0
- package/nugget/src/transport.ts +148 -0
- package/nugget/src/types.ts +175 -0
- package/nugget/src/util.ts +51 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jxburros
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# AI Nugget
|
|
2
|
+
|
|
3
|
+
`AI Nugget` is a small, zero-dependency, isomorphic TypeScript nugget for
|
|
4
|
+
talking to AI model providers through one pipeline:
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
policy → key resolution → beforeCall hook → concurrency/retry → provider adapter → redacted telemetry
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
It runs identically in Node ≥ 20, the browser main thread, and Web Workers using
|
|
11
|
+
only `fetch`, `ReadableStream`, `AbortController`, and `TextDecoder`. It is
|
|
12
|
+
intentionally **not** a router, prompt library, memory system, or secrets vault:
|
|
13
|
+
apps choose models, own prompts, store secrets, and decide what policy to enforce.
|
|
14
|
+
The package is MIT licensed.
|
|
15
|
+
|
|
16
|
+
See `design.md` for the full contract. The original evidence base, phased
|
|
17
|
+
build/adoption plan, and dev handoff are archived under `docs/archive/`.
|
|
18
|
+
|
|
19
|
+
## Install / use
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { AIHandler, envKeySource } from '@jxburros/ai-nugget';
|
|
23
|
+
|
|
24
|
+
const handler = new AIHandler({ keySource: envKeySource() });
|
|
25
|
+
|
|
26
|
+
// Non-streaming
|
|
27
|
+
const result = await handler.chat(
|
|
28
|
+
{ id: 'main', provider: 'openai', keyRef: { kind: 'env', name: 'OPENAI_API_KEY' } },
|
|
29
|
+
{ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'Hello!' }] },
|
|
30
|
+
);
|
|
31
|
+
console.log(result.text, result.usage, result.finishReason);
|
|
32
|
+
|
|
33
|
+
// Streaming (works in Node route handlers, React, and Web Workers)
|
|
34
|
+
for await (const event of handler.stream(conn, req)) {
|
|
35
|
+
if (event.type === 'delta') process.stdout.write(event.text);
|
|
36
|
+
if (event.type === 'done') console.log('\n', event.result.usage);
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Repos that cannot take a package dependency can vendor the generated `nugget/`
|
|
41
|
+
folder (see below).
|
|
42
|
+
|
|
43
|
+
## Providers
|
|
44
|
+
|
|
45
|
+
Four **protocol engines** own the wire formats; a data-driven **profile table**
|
|
46
|
+
turns each named provider into defaults + quirks on top of an engine, so adding
|
|
47
|
+
an OpenAI-compatible provider is a table row, not another copy of the SSE loop.
|
|
48
|
+
|
|
49
|
+
| Engine | Providers | Streaming | Tools | JSON mode |
|
|
50
|
+
|---|---|---|---|---|
|
|
51
|
+
| `openaiChat` | `openai`, `azure-openai`, `openrouter`, `groq`, `deepseek`, `mistral`, `together`, `fireworks`, `lmstudio`, `llamacpp`, `vllm`, `openai-compat` | SSE + `stream_options.include_usage` | `tools`/`tool_calls` deltas | `response_format` (`json_schema` where supported, otherwise `json_object`) |
|
|
52
|
+
| `anthropic` | `anthropic` | SSE (`content_block_delta`) | `tool_use` blocks (streamed) | forced-tool JSON mode |
|
|
53
|
+
| `google` | `google` | SSE (`streamGenerateContent`) | `functionDeclarations` | `responseMimeType` |
|
|
54
|
+
| `ollama` | `ollama` | NDJSON | native `tools` | `format: json` |
|
|
55
|
+
|
|
56
|
+
`adapterFor(provider)` lives once, in `adapters/index.ts`. Unknown provider keys
|
|
57
|
+
with a `baseUrl` resolve to the `openai-compat` escape hatch. **Grok/xAI is not
|
|
58
|
+
blocked and is never officially integrated** — point `openai-compat` at any
|
|
59
|
+
endpoint if you must; that is your configuration, not a supported profile.
|
|
60
|
+
|
|
61
|
+
Model identity is always `(source, model)` — `modelRef(source, model)` gives the
|
|
62
|
+
canonical `provider/model` key so the same weights served by different hosts stay
|
|
63
|
+
distinct and comparable.
|
|
64
|
+
|
|
65
|
+
### Provider capabilities
|
|
66
|
+
|
|
67
|
+
Beyond wire-format `quirks` (auth, URL templates, whether a stream reports
|
|
68
|
+
usage), each profile also carries `capabilities` — the higher-level questions
|
|
69
|
+
apps need answered before they pick behavior:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
interface ProviderCapabilities {
|
|
73
|
+
nativeTools: boolean; // reliable native tool/function-calling for this
|
|
74
|
+
// provider's protocol (not just model-dependent)
|
|
75
|
+
jsonMode: boolean; // a provider-enforced structured-output mode exists
|
|
76
|
+
local: boolean; // runs on localhost / user-controlled infra
|
|
77
|
+
embeddable: boolean; // meant to run as a local sidecar, not a hosted service
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Hosted cloud providers (`openai`, `azure-openai`, `openrouter`, `groq`,
|
|
82
|
+
`deepseek`, `mistral`, `together`, `fireworks`, `anthropic`, `google`) ship
|
|
83
|
+
`{ nativeTools: true, jsonMode: true, local: false, embeddable: false }`. Local
|
|
84
|
+
runtimes (`lmstudio`, `llamacpp`, `vllm`, and the `openai-compat` escape hatch)
|
|
85
|
+
ship the conservative `{ nativeTools: false, jsonMode: false, local: true,
|
|
86
|
+
embeddable: true }`, since native tool support there is model-dependent, not
|
|
87
|
+
protocol-guaranteed. `ollama` is the one exception with `jsonMode: true` —
|
|
88
|
+
its `format: 'json'` is engine-enforced regardless of which model is loaded.
|
|
89
|
+
These are per-provider defaults, not per-model guarantees: a caller that knows
|
|
90
|
+
its specific loaded model better should configure behavior explicitly (e.g.
|
|
91
|
+
`toolMode: 'native'`) rather than rely on the default.
|
|
92
|
+
|
|
93
|
+
Access via `profileFor(provider, baseUrl).capabilities`.
|
|
94
|
+
|
|
95
|
+
**These are provider-level defaults, not per-model guarantees.** A specific
|
|
96
|
+
model behind a "hosted" provider can still lack reliable native tool-calling
|
|
97
|
+
(and a specific local model behind a "local" provider can have it) — this
|
|
98
|
+
matters most for OpenRouter, Ollama, LM Studio, vLLM, and any
|
|
99
|
+
`openai-compat` endpoint, where the model actually loaded is opaque to the
|
|
100
|
+
profile table. A caller that knows its model better should pass an explicit
|
|
101
|
+
`toolMode` (agent layer) rather than rely on the capability default.
|
|
102
|
+
|
|
103
|
+
**`listModels()` is optional per adapter — two engines never return anything.**
|
|
104
|
+
`ProviderAdapter.listModels` is `?`-optional, and `AIHandler.listModels()`
|
|
105
|
+
falls back to `[]` when an adapter doesn't implement it. Concretely: the
|
|
106
|
+
`openaiChat` and `ollama` engines implement it (`GET /models` /
|
|
107
|
+
`GET /api/tags`); the `anthropic` and `google` engines currently don't, so
|
|
108
|
+
`handler.listModels()` for those providers always resolves to `[]`, not an
|
|
109
|
+
error. This is easy to miss because `capabilities` doesn't surface it — an
|
|
110
|
+
app building a model picker on top of `listModels()` should treat an empty
|
|
111
|
+
result as "this provider doesn't support discovery," not "this provider has
|
|
112
|
+
no models," and have a fallback (e.g. an app-configured default model per
|
|
113
|
+
connection) ready for `anthropic`/`google` rather than showing an empty list.
|
|
114
|
+
|
|
115
|
+
### Letting an app's users pick a model (best practice)
|
|
116
|
+
|
|
117
|
+
The nugget deliberately has no concept of "the app's currently selected
|
|
118
|
+
model" — that's app policy, same as governance (see Non-goals). The pattern
|
|
119
|
+
that has worked well for apps building a model-picker UI on top of the
|
|
120
|
+
AI Nugget handler:
|
|
121
|
+
|
|
122
|
+
1. **Keep `provider`/`baseUrl` on a server-side allowlist; never take them
|
|
123
|
+
from client input.** A `Connection`'s `provider`/`baseUrl` decide where
|
|
124
|
+
the server's resolved API key gets sent (via `KeySource` → `applyAuth`);
|
|
125
|
+
letting a client control either directly is effectively letting it
|
|
126
|
+
redirect a real credential to an arbitrary endpoint (SSRF plus key
|
|
127
|
+
exposure). Define your app's available connections server-side (e.g. a
|
|
128
|
+
small config list, each with its own `KeyRef`), and only let the client
|
|
129
|
+
choose a `connectionId` out of that list.
|
|
130
|
+
2. **`model` is fine as ordinary client input.** Unlike `provider`/`baseUrl`,
|
|
131
|
+
a bad or unexpected `model` string just comes back as a normal provider
|
|
132
|
+
error (unknown model) from `chat()`/`stream()` — there's no equivalent
|
|
133
|
+
security concern, so there's no need to allowlist it server-side beyond
|
|
134
|
+
what the provider itself enforces.
|
|
135
|
+
3. **Use `handler.listModels(connection)` to populate the picker live**, and
|
|
136
|
+
fall back to an app-configured default model for connections whose engine
|
|
137
|
+
doesn't implement discovery (see above).
|
|
138
|
+
|
|
139
|
+
See `examples/model-picker.mjs` for a minimal runnable reference implementing
|
|
140
|
+
this pattern.
|
|
141
|
+
|
|
142
|
+
## What's implemented
|
|
143
|
+
|
|
144
|
+
- **Core:** message-based contracts (`types.ts`), typed `AIError` + `classify()`,
|
|
145
|
+
timeout/abort merging and SSE/NDJSON readers (`transport.ts`), defensive JSON
|
|
146
|
+
extraction with array support + schema guards (`json.ts`), token estimation with
|
|
147
|
+
an explicit `estimated` flag (`tokens.ts`).
|
|
148
|
+
- **Adapters:** all four engines with streaming, native tool-calling, JSON modes,
|
|
149
|
+
`finishReason` mapping, buffered-`stream:true` fallback, first-token latency,
|
|
150
|
+
and usage normalization; the full v1 provider profile table. The
|
|
151
|
+
OpenAI-compatible engine only sends `stream_options: { include_usage: true }`
|
|
152
|
+
when the profile's `supportsUsageInStream` quirk confirms the server expects
|
|
153
|
+
it — local servers (llama.cpp, LM Studio, vLLM) that don't have the quirk set
|
|
154
|
+
never receive the option. The OpenAI and Azure OpenAI profiles send
|
|
155
|
+
`max_completion_tokens` for `maxTokens`; local/OpenAI-compatible profiles keep
|
|
156
|
+
`max_tokens` for compatibility. Google receives `responseSchema` for JSON mode
|
|
157
|
+
only when native tools are absent, avoiding Gemini's JSON-with-tools rejection.
|
|
158
|
+
- **Pipeline (`AIHandler`):** policy → keys → `beforeCall` → concurrency/min-interval
|
|
159
|
+
→ jittered retry (honoring `Retry-After`) → redacted `CallRecord` telemetry →
|
|
160
|
+
`afterCall`, with `chat`, `stream`, `listModels`, `testConnection`. Every call —
|
|
161
|
+
success, failure, or consumer-abandoned stream — produces one redacted telemetry
|
|
162
|
+
record. Retries stop once user-visible output has been emitted, and telemetry
|
|
163
|
+
or `afterCall` failures never re-execute an already-successful provider call.
|
|
164
|
+
`listModels()` and `testConnection()` use explicit operation IDs
|
|
165
|
+
(`__listModels__`, `__testConnection__`) so policy and telemetry can see those
|
|
166
|
+
key-bearing probes too.
|
|
167
|
+
- **Seams:** `KeySource` (`env`/`literal`/`memory`/chain + ref parsing),
|
|
168
|
+
`Redactor` (default secret patterns + session-resolved key redaction),
|
|
169
|
+
neutral `GovernancePolicy` (`blocklistPolicy`/`allowlistPolicy`/`composePolicies`).
|
|
170
|
+
`allowlistPolicy` fails closed for providers omitted from the configured map;
|
|
171
|
+
use `'*'` in a provider's prefix list to allow non-chat operation IDs.
|
|
172
|
+
The default redactor covers common provider token formats and generic bearer
|
|
173
|
+
tokens, with session-resolved keys always added exactly.
|
|
174
|
+
- **Agent layer (`@jxburros/ai-nugget/agent`):** `defineTool` + light JSON-schema
|
|
175
|
+
arg validation (object-ness, `required`, top-level `properties[key].type` —
|
|
176
|
+
not full JSON Schema: no `enum`, nested schemas, `oneOf`, bounds, `pattern`,
|
|
177
|
+
array `items`, or `additionalProperties`; validate again inside
|
|
178
|
+
`tool.execute` if you need stricter guarantees), `runAgent()` model↔tool loop
|
|
179
|
+
over the full handler pipeline,
|
|
180
|
+
streamed `AgentEvent`s, budgets (`maxSteps`/`maxTokens`/`deadlineMs`) with honest
|
|
181
|
+
`stopReason`s, `ApprovalGate` for side-effecting tools (deny is fed back to the
|
|
182
|
+
model as data), and `native` / `promptJson` / `auto` tool modes. `auto` (the
|
|
183
|
+
default) resolves per call from the connection's provider capability profile
|
|
184
|
+
(`profileFor(provider).capabilities.nativeTools`) — hosted providers get
|
|
185
|
+
`native`, local runtimes and the `openai-compat` escape hatch get
|
|
186
|
+
`promptJson`, and an explicit `toolMode` always overrides the default.
|
|
187
|
+
`promptJson` mode accepts a single `{"tool","input"}` directive or a batched
|
|
188
|
+
`{"tools":[…]}` (a bare array works too), so the model can request several
|
|
189
|
+
tools in one turn just like native tool-calling. Prompt-JSON history is
|
|
190
|
+
serialized as plain text turns, not provider-native tool-call wire format.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { runAgent, defineTool } from '@jxburros/ai-nugget/agent';
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Commands
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
npm install
|
|
200
|
+
npm test # Vitest contract suite in Node (97 tests; live tests skipped unless env-gated)
|
|
201
|
+
npm run test:browser # same suite in headless Chromium (proves isomorphism)
|
|
202
|
+
npm run build # tsc → dist/ (also the typecheck)
|
|
203
|
+
npm run build:nugget # writes a vendorable nugget/ stamped with version + content hash
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Live smoke tests (optional, env-gated)
|
|
207
|
+
|
|
208
|
+
`tests/live-smoke.test.ts` exercises the real wire path against a live provider.
|
|
209
|
+
It is **skipped unless `AI_HANDLER_LIVE=1`**, so it never runs in the default
|
|
210
|
+
suite or in CI:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
# Local Ollama (defaults: provider=ollama, model=llama3.2)
|
|
214
|
+
AI_HANDLER_LIVE=1 npm run test:live
|
|
215
|
+
|
|
216
|
+
# A cloud provider, with JSON-mode and agent tool-loop checks enabled
|
|
217
|
+
AI_HANDLER_LIVE=1 AI_HANDLER_LIVE_PROVIDER=openai AI_HANDLER_LIVE_MODEL=gpt-4o-mini \
|
|
218
|
+
AI_HANDLER_LIVE_KEY_ENV=OPENAI_API_KEY AI_HANDLER_LIVE_JSON=1 AI_HANDLER_LIVE_TOOLS=1 \
|
|
219
|
+
npm run test:live
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Config env vars: `AI_HANDLER_LIVE_PROVIDER`, `AI_HANDLER_LIVE_MODEL`,
|
|
223
|
+
`AI_HANDLER_LIVE_BASE_URL`, `AI_HANDLER_LIVE_KEY` (literal) or
|
|
224
|
+
`AI_HANDLER_LIVE_KEY_ENV` (key from an env var), `AI_HANDLER_LIVE_JSON`,
|
|
225
|
+
`AI_HANDLER_LIVE_TOOLS`.
|
|
226
|
+
|
|
227
|
+
**Live provider matrix (`.github/workflows/live-matrix.yml`).** The default
|
|
228
|
+
CI workflow only exercises mocked fetch responses, so subtle live-wire
|
|
229
|
+
behavior differences (OpenAI, Anthropic, Google, OpenRouter, and local
|
|
230
|
+
runtimes each drift independently) can't surface there. `live-matrix.yml`
|
|
231
|
+
runs the same `tests/live-smoke.test.ts` suite against real endpoints —
|
|
232
|
+
manually (`workflow_dispatch`) or on a weekly schedule — never on push/PR, so
|
|
233
|
+
it can't block or flake a normal CI run. Each matrix entry reads its API key
|
|
234
|
+
from a same-named repository secret (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
|
|
235
|
+
`GOOGLE_API_KEY`, `OPENROUTER_API_KEY`) and skips itself with a notice if that
|
|
236
|
+
secret isn't configured, so you only need to add secrets for the providers
|
|
237
|
+
you actually use. Local runtimes (Ollama, llama.cpp, LM Studio, vLLM) aren't
|
|
238
|
+
in the matrix since they need a reachable server; smoke-test those manually
|
|
239
|
+
with the local `test:live` invocation above.
|
|
240
|
+
|
|
241
|
+
## Distribution
|
|
242
|
+
|
|
243
|
+
Three supported paths, in order of preference:
|
|
244
|
+
|
|
245
|
+
1. **npm registry (primary).** `@jxburros/ai-nugget` publishes to
|
|
246
|
+
[npmjs.org](https://www.npmjs.com/package/@jxburros/ai-nugget) on every
|
|
247
|
+
published GitHub release (`.github/workflows/publish.yml`). Consumers install
|
|
248
|
+
from the normal npm registry:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
npm install @jxburros/ai-nugget@^0.3.1
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
This is the default dependency path for apps: fix once here, bump the
|
|
255
|
+
dependency in each app, and let npm resolve it normally.
|
|
256
|
+
2. **GitHub Packages (kept).** The same release workflow also publishes
|
|
257
|
+
`@jxburros/ai-nugget` to GitHub Packages for GitHub-native workflows and
|
|
258
|
+
existing portfolio consumers that already point at that registry. Those apps
|
|
259
|
+
add a project `.npmrc`:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
@jxburros:registry=https://npm.pkg.github.com
|
|
263
|
+
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
(GitHub Packages requires an authenticated token even for public-repo
|
|
267
|
+
packages.) Then `npm install @jxburros/ai-nugget@^0.3.1`.
|
|
268
|
+
3. **Vendored `nugget/` (fallback).** `nugget/` is a generated single-folder
|
|
269
|
+
build (`src/` + `VERSION.txt` with a version + content-hash stamp) for repos
|
|
270
|
+
that cannot take a package dependency. Copy it in; `VERSION.txt` makes drift
|
|
271
|
+
from the source of truth detectable. (Bundlers that don't resolve
|
|
272
|
+
`.ts`+`.js`-suffixed imports the way `tsc` does — e.g. Turbopack — should
|
|
273
|
+
vendor `dist/` instead; see below.)
|
|
274
|
+
|
|
275
|
+
`dist/` (ESM + `.d.ts`) is the package's own build output. `dist/` and
|
|
276
|
+
`nugget/` are both committed and regenerated from `src/`; `prepublishOnly`
|
|
277
|
+
rebuilds both, and CI fails if either is stale.
|
|
278
|
+
|
|
279
|
+
**Bundler compatibility for the vendored `nugget/` path.** `nugget/src/*.ts`
|
|
280
|
+
uses NodeNext-style relative imports with explicit `.js` extensions (e.g.
|
|
281
|
+
`export * from './types.js'`), which `tsc` needs to resolve `.ts` files under
|
|
282
|
+
`moduleResolution: bundler`/`nodenext`. Not every bundler's runtime module
|
|
283
|
+
graph treats `.ts`/`.js` as interchangeable the way `tsc` does — confirmed
|
|
284
|
+
with Next.js 16's Turbopack: aliasing straight at `nugget/src/index.ts`
|
|
285
|
+
type-checks but fails to resolve at build/dev time (`Module not found: Can't
|
|
286
|
+
resolve './types.js'`), and aliasing at the compiled `dist/index.d.ts` builds
|
|
287
|
+
but silently resolves some named exports to `undefined` at runtime. If your
|
|
288
|
+
bundler hits this, vendor `dist/` (real `.js` + `.d.ts` pairs) instead of
|
|
289
|
+
`nugget/`, and point path aliases at its `.js` entry points (e.g.
|
|
290
|
+
`dist/index.js`, `dist/agent/index.js`) — TypeScript picks up the sibling
|
|
291
|
+
`.d.ts` automatically.
|
|
292
|
+
|
|
293
|
+
## Examples
|
|
294
|
+
|
|
295
|
+
`examples/` has runnable scripts against local Ollama and llama.cpp servers,
|
|
296
|
+
`promptJson` vs. native tool-calling, an `ApprovalGate`, and telemetry — see
|
|
297
|
+
`examples/README.md`.
|
|
298
|
+
|
|
299
|
+
## Agent skills
|
|
300
|
+
|
|
301
|
+
`.claude/skills/` contains Agent Skills — task-specific guides that Claude Code
|
|
302
|
+
picks up automatically and that any AI agent (or human) can read as plain
|
|
303
|
+
Markdown: `use-ai-nugget` (integrating the nugget into an app),
|
|
304
|
+
`build-agent-loop` (the agent/tool-calling layer), `add-provider` (extending
|
|
305
|
+
the provider profile table), and `develop-nugget` (invariants and validation
|
|
306
|
+
for changes to this repo). `AGENTS.md` remains the always-on baseline; the
|
|
307
|
+
skills carry the per-task detail.
|
|
308
|
+
|
|
309
|
+
## Non-goals
|
|
310
|
+
|
|
311
|
+
Not a full agent framework (no planning/memory/RAG/multi-agent — the loop
|
|
312
|
+
consumes `messages` and stops there), not a router/recommender (apps choose
|
|
313
|
+
models; the handler exposes a `route`/`context` event so apps can report what they
|
|
314
|
+
chose), not a secrets vault (`KeySource` is a seam), and not a UI. The governance
|
|
315
|
+
seam ships neutral: it is where an app *can* enforce rules, not a place the
|
|
316
|
+
library imposes its own.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ChatRequest, ChatResult, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
|
|
2
|
+
export declare class AnthropicAdapter implements ProviderAdapter {
|
|
3
|
+
readonly provider: string;
|
|
4
|
+
constructor(provider: string);
|
|
5
|
+
chat(conn: ResolvedConnection, req: ChatRequest): Promise<ChatResult>;
|
|
6
|
+
stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent>;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=anthropic.d.ts.map
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { AIError } from '../../errors.js';
|
|
2
|
+
import { estimatedUsage } from '../../tokens.js';
|
|
3
|
+
import { postResponse, sseLines } from '../../transport.js';
|
|
4
|
+
import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
|
|
5
|
+
import { streamError, streamTimeout } from './base.js';
|
|
6
|
+
const JSON_MODE_TOOL = 'json_output';
|
|
7
|
+
export class AnthropicAdapter {
|
|
8
|
+
provider;
|
|
9
|
+
constructor(provider) {
|
|
10
|
+
this.provider = provider;
|
|
11
|
+
}
|
|
12
|
+
async chat(conn, req) {
|
|
13
|
+
let result;
|
|
14
|
+
for await (const event of this.stream(conn, req)) {
|
|
15
|
+
if (event.type === 'done')
|
|
16
|
+
result = event.result;
|
|
17
|
+
if (event.type === 'error')
|
|
18
|
+
throw event.error;
|
|
19
|
+
}
|
|
20
|
+
if (!result)
|
|
21
|
+
throw new AIError('Anthropic stream ended without a result', { kind: 'invalid_response', provider: conn.provider });
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
async *stream(conn, req) {
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
const jsonMode = req.responseFormat?.type === 'json' && !req.tools?.length;
|
|
27
|
+
let firstTokenMs = null;
|
|
28
|
+
let text = '';
|
|
29
|
+
let inputTokens;
|
|
30
|
+
let outputTokens;
|
|
31
|
+
let stopReason;
|
|
32
|
+
const emittedTools = [];
|
|
33
|
+
// Partial tool_use blocks keyed by content-block index (input_json_delta accumulation).
|
|
34
|
+
const blocks = new Map();
|
|
35
|
+
const timeout = streamTimeout(conn, req.signal);
|
|
36
|
+
yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
|
|
37
|
+
try {
|
|
38
|
+
const res = await postResponse(`${conn.baseUrl}/v1/messages`, body(req, jsonMode), conn.headers, timeout.signal, conn.provider);
|
|
39
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
40
|
+
if (!contentType.includes('text/event-stream')) {
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
const parsed = parseResponse(data);
|
|
43
|
+
inputTokens = parsed.inputTokens;
|
|
44
|
+
outputTokens = parsed.outputTokens;
|
|
45
|
+
stopReason = parsed.stopReason;
|
|
46
|
+
if (jsonMode) {
|
|
47
|
+
text = jsonTextFrom(parsed.toolCalls, parsed.text);
|
|
48
|
+
if (text)
|
|
49
|
+
yield { type: 'delta', text };
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
text = parsed.text;
|
|
53
|
+
if (text)
|
|
54
|
+
yield { type: 'delta', text };
|
|
55
|
+
for (const call of parsed.toolCalls) {
|
|
56
|
+
emittedTools.push(call);
|
|
57
|
+
yield { type: 'tool_call', call };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
for await (const line of sseLines(res)) {
|
|
63
|
+
const record = asRecord(safeParse(line));
|
|
64
|
+
if (!record)
|
|
65
|
+
continue;
|
|
66
|
+
const type = asString(record.type);
|
|
67
|
+
if (type === 'message_start') {
|
|
68
|
+
const usage = asRecord(asRecord(record.message)?.usage);
|
|
69
|
+
inputTokens = asNumber(usage?.input_tokens) ?? inputTokens;
|
|
70
|
+
outputTokens = asNumber(usage?.output_tokens) ?? outputTokens;
|
|
71
|
+
}
|
|
72
|
+
else if (type === 'content_block_start') {
|
|
73
|
+
const index = asNumber(record.index) ?? 0;
|
|
74
|
+
const block = asRecord(record.content_block);
|
|
75
|
+
if (block?.type === 'tool_use') {
|
|
76
|
+
blocks.set(index, { id: asString(block.id) ?? randomId(), name: asString(block.name) ?? 'unknown', raw: '' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (type === 'content_block_delta') {
|
|
80
|
+
const index = asNumber(record.index) ?? 0;
|
|
81
|
+
const delta = asRecord(record.delta);
|
|
82
|
+
if (delta?.type === 'text_delta') {
|
|
83
|
+
const piece = asString(delta.text) ?? '';
|
|
84
|
+
if (piece) {
|
|
85
|
+
if (firstTokenMs === null)
|
|
86
|
+
firstTokenMs = Date.now() - started;
|
|
87
|
+
text += piece;
|
|
88
|
+
yield { type: 'delta', text: piece };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (delta?.type === 'input_json_delta') {
|
|
92
|
+
const partial = blocks.get(index);
|
|
93
|
+
if (partial)
|
|
94
|
+
partial.raw += asString(delta.partial_json) ?? '';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (type === 'content_block_stop') {
|
|
98
|
+
const index = asNumber(record.index) ?? 0;
|
|
99
|
+
const partial = blocks.get(index);
|
|
100
|
+
if (partial) {
|
|
101
|
+
const call = { id: partial.id, name: partial.name, raw: partial.raw, arguments: parseArgs(partial.raw) };
|
|
102
|
+
blocks.delete(index);
|
|
103
|
+
if (jsonMode && call.name === JSON_MODE_TOOL) {
|
|
104
|
+
text = jsonTextFrom([call], text);
|
|
105
|
+
if (firstTokenMs === null)
|
|
106
|
+
firstTokenMs = Date.now() - started;
|
|
107
|
+
yield { type: 'delta', text };
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
emittedTools.push(call);
|
|
111
|
+
yield { type: 'tool_call', call };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (type === 'message_delta') {
|
|
116
|
+
const delta = asRecord(record.delta);
|
|
117
|
+
stopReason = asString(delta?.stop_reason) ?? stopReason;
|
|
118
|
+
const usage = asRecord(record.usage);
|
|
119
|
+
outputTokens = asNumber(usage?.output_tokens) ?? outputTokens;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const hasTools = emittedTools.length > 0;
|
|
124
|
+
yield { type: 'done', result: {
|
|
125
|
+
text,
|
|
126
|
+
toolCalls: hasTools ? emittedTools : undefined,
|
|
127
|
+
finishReason: mapStop(stopReason, hasTools),
|
|
128
|
+
usage: inputTokens !== undefined || outputTokens !== undefined ? { inputTokens, outputTokens, estimated: false } : estimatedUsage(textFromMessages(req.messages), text),
|
|
129
|
+
timing: { firstTokenMs, totalMs: Date.now() - started },
|
|
130
|
+
model: req.model,
|
|
131
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
|
|
132
|
+
} };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
throw streamError(error, timeout, conn.provider);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
timeout.done();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function body(req, jsonMode) {
|
|
143
|
+
const system = req.messages.filter((m) => m.role === 'system').map((m) => textContent(m.content)).join('\n\n') || undefined;
|
|
144
|
+
const base = {
|
|
145
|
+
model: req.model,
|
|
146
|
+
max_tokens: req.maxTokens ?? 4096,
|
|
147
|
+
temperature: req.temperature,
|
|
148
|
+
top_p: req.topP,
|
|
149
|
+
stop_sequences: req.stopSequences,
|
|
150
|
+
system,
|
|
151
|
+
messages: toAnthropicMessages(req.messages.filter((m) => m.role !== 'system')),
|
|
152
|
+
stream: true,
|
|
153
|
+
};
|
|
154
|
+
if (jsonMode) {
|
|
155
|
+
// Forced-tool JSON mode: Anthropic has no response_format, so we make the
|
|
156
|
+
// model answer through a single required tool whose input is the JSON we want.
|
|
157
|
+
base.tools = [{ name: JSON_MODE_TOOL, description: 'Return the answer as a JSON object.', input_schema: schemaFor(req) }];
|
|
158
|
+
base.tool_choice = { type: 'tool', name: JSON_MODE_TOOL };
|
|
159
|
+
}
|
|
160
|
+
else if (req.tools?.length) {
|
|
161
|
+
base.tools = req.tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters }));
|
|
162
|
+
if (typeof req.toolChoice === 'object')
|
|
163
|
+
base.tool_choice = { type: 'tool', name: req.toolChoice.name };
|
|
164
|
+
else if (req.toolChoice === 'auto')
|
|
165
|
+
base.tool_choice = { type: 'auto' };
|
|
166
|
+
else if (req.toolChoice === 'none')
|
|
167
|
+
base.tool_choice = { type: 'none' };
|
|
168
|
+
}
|
|
169
|
+
return base;
|
|
170
|
+
}
|
|
171
|
+
function toAnthropicMessages(messages) {
|
|
172
|
+
const out = [];
|
|
173
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
174
|
+
const message = messages[index];
|
|
175
|
+
if (message.role !== 'tool') {
|
|
176
|
+
out.push(toAnthropicMessage(message));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const content = [];
|
|
180
|
+
while (index < messages.length && messages[index]?.role === 'tool') {
|
|
181
|
+
const toolMessage = messages[index];
|
|
182
|
+
content.push({ type: 'tool_result', tool_use_id: toolMessage.toolCallId ?? '', content: textContent(toolMessage.content) });
|
|
183
|
+
index += 1;
|
|
184
|
+
}
|
|
185
|
+
index -= 1;
|
|
186
|
+
out.push({ role: 'user', content });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
function toAnthropicMessage(m) {
|
|
191
|
+
// tool result messages map to a user turn carrying a tool_result content block.
|
|
192
|
+
if (m.role === 'tool') {
|
|
193
|
+
return {
|
|
194
|
+
role: 'user',
|
|
195
|
+
content: [{ type: 'tool_result', tool_use_id: m.toolCallId ?? '', content: typeof m.content === 'string' ? m.content : '' }],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// assistant turns that carried tool calls replay them as tool_use blocks.
|
|
199
|
+
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
200
|
+
const content = [];
|
|
201
|
+
if (typeof m.content === 'string' && m.content)
|
|
202
|
+
content.push({ type: 'text', text: m.content });
|
|
203
|
+
for (const call of m.toolCalls)
|
|
204
|
+
content.push({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments ?? {} });
|
|
205
|
+
return { role: 'assistant', content };
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
209
|
+
content: typeof m.content === 'string' ? m.content : m.content.map((part) => part.type === 'image'
|
|
210
|
+
? { type: 'image', source: { type: 'base64', media_type: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
|
|
211
|
+
: { type: 'text', text: part.text ?? '' }),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function textContent(content) {
|
|
215
|
+
if (typeof content === 'string')
|
|
216
|
+
return content;
|
|
217
|
+
if (!content)
|
|
218
|
+
return '';
|
|
219
|
+
return content.filter((part) => part.type === 'text').map((part) => part.text ?? '').join('\n');
|
|
220
|
+
}
|
|
221
|
+
function parseResponse(data) {
|
|
222
|
+
const record = asRecord(data);
|
|
223
|
+
const content = Array.isArray(record?.content) ? record.content : [];
|
|
224
|
+
let text = '';
|
|
225
|
+
const toolCalls = [];
|
|
226
|
+
for (const blockValue of content) {
|
|
227
|
+
const block = asRecord(blockValue);
|
|
228
|
+
if (block?.type === 'text')
|
|
229
|
+
text += asString(block.text) ?? '';
|
|
230
|
+
if (block?.type === 'tool_use') {
|
|
231
|
+
toolCalls.push({
|
|
232
|
+
id: asString(block.id) ?? randomId(),
|
|
233
|
+
name: asString(block.name) ?? 'unknown',
|
|
234
|
+
arguments: block.input ?? {},
|
|
235
|
+
raw: JSON.stringify(block.input ?? {}),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const usage = asRecord(record?.usage);
|
|
240
|
+
return { text, inputTokens: asNumber(usage?.input_tokens), outputTokens: asNumber(usage?.output_tokens), stopReason: asString(record?.stop_reason), toolCalls };
|
|
241
|
+
}
|
|
242
|
+
function schemaFor(req) {
|
|
243
|
+
const schema = req.responseFormat?.type === 'json' ? req.responseFormat.schema : undefined;
|
|
244
|
+
return schema ?? { type: 'object' };
|
|
245
|
+
}
|
|
246
|
+
function jsonTextFrom(toolCalls, fallback) {
|
|
247
|
+
const json = toolCalls.find((call) => call.name === JSON_MODE_TOOL) ?? toolCalls[0];
|
|
248
|
+
if (json)
|
|
249
|
+
return json.raw && json.raw.trim() ? json.raw : JSON.stringify(json.arguments ?? {});
|
|
250
|
+
return fallback;
|
|
251
|
+
}
|
|
252
|
+
function mapStop(stop, hasTools) {
|
|
253
|
+
if (stop === 'tool_use' && hasTools)
|
|
254
|
+
return 'tool';
|
|
255
|
+
if (stop === 'max_tokens')
|
|
256
|
+
return 'length';
|
|
257
|
+
return 'stop';
|
|
258
|
+
}
|
|
259
|
+
function parseArgs(raw) {
|
|
260
|
+
try {
|
|
261
|
+
return raw ? JSON.parse(raw) : {};
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return {};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function safeParse(line) {
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(line);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function randomId() {
|
|
276
|
+
return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=anthropic.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AIError } from '../../errors.js';
|
|
2
|
+
import { withTimeout } from '../../transport.js';
|
|
3
|
+
import type { ModelInfo, ResolvedConnection } from '../../types.js';
|
|
4
|
+
import type { ProviderProfile } from '../profiles.js';
|
|
5
|
+
export declare const DEFAULT_TIMEOUT_MS = 120000;
|
|
6
|
+
/**
|
|
7
|
+
* Maps a raw streaming failure onto a typed {@link AIError}. A fired timeout is
|
|
8
|
+
* reported as `timeout` regardless of how the underlying `fetch` surfaced the
|
|
9
|
+
* abort; anything else is normalized through {@link fromUnknown} (which passes
|
|
10
|
+
* an existing {@link AIError} through untouched, preserving retryability).
|
|
11
|
+
*/
|
|
12
|
+
export declare function streamError(error: unknown, timeout: {
|
|
13
|
+
timedOut(): boolean;
|
|
14
|
+
}, provider: string): AIError;
|
|
15
|
+
/** Create a timeout/abort scope covering the full stream lifetime. */
|
|
16
|
+
export declare function streamTimeout(conn: ResolvedConnection, signal?: AbortSignal): ReturnType<typeof withTimeout>;
|
|
17
|
+
export declare function listOpenModels(conn: ResolvedConnection, profile: ProviderProfile): Promise<ModelInfo[]>;
|
|
18
|
+
export declare function health(conn: ResolvedConnection, profile: ProviderProfile): Promise<{
|
|
19
|
+
ok: boolean;
|
|
20
|
+
detail?: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function requireResponse(condition: unknown, message: string): asserts condition;
|
|
23
|
+
//# sourceMappingURL=base.d.ts.map
|