@jxburros/ai-nugget 0.3.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +134 -271
  2. package/UPGRADING.md +158 -0
  3. package/dist/adapters/engines/anthropic.d.ts +7 -1
  4. package/dist/adapters/engines/anthropic.js +64 -37
  5. package/dist/adapters/engines/base.d.ts +29 -2
  6. package/dist/adapters/engines/base.js +52 -8
  7. package/dist/adapters/engines/google.d.ts +17 -1
  8. package/dist/adapters/engines/google.js +93 -29
  9. package/dist/adapters/engines/ollama.d.ts +2 -1
  10. package/dist/adapters/engines/ollama.js +51 -15
  11. package/dist/adapters/engines/openaiChat.d.ts +2 -1
  12. package/dist/adapters/engines/openaiChat.js +60 -30
  13. package/dist/adapters/profiles.d.ts +21 -1
  14. package/dist/adapters/profiles.js +12 -2
  15. package/dist/agent/loop.d.ts +43 -1
  16. package/dist/agent/loop.js +120 -13
  17. package/dist/cjs/adapters/engines/anthropic.js +308 -0
  18. package/dist/cjs/adapters/engines/base.js +147 -0
  19. package/dist/cjs/adapters/engines/google.js +284 -0
  20. package/dist/cjs/adapters/engines/ollama.js +199 -0
  21. package/dist/cjs/adapters/engines/openaiChat.js +261 -0
  22. package/dist/cjs/adapters/index.js +22 -0
  23. package/dist/cjs/adapters/profiles.js +125 -0
  24. package/dist/cjs/agent/index.js +18 -0
  25. package/dist/cjs/agent/loop.js +372 -0
  26. package/dist/cjs/agent/tools.js +51 -0
  27. package/dist/cjs/connect.js +27 -0
  28. package/dist/cjs/errors.js +110 -0
  29. package/dist/cjs/handler.js +561 -0
  30. package/dist/cjs/index.js +27 -0
  31. package/dist/cjs/json.js +121 -0
  32. package/dist/cjs/keys.js +81 -0
  33. package/dist/cjs/package.json +3 -0
  34. package/dist/cjs/policy.js +42 -0
  35. package/dist/cjs/redact.js +78 -0
  36. package/dist/cjs/tokens.js +22 -0
  37. package/dist/cjs/transport.js +172 -0
  38. package/dist/cjs/types.js +6 -0
  39. package/dist/cjs/util.js +112 -0
  40. package/dist/connect.d.ts +31 -0
  41. package/dist/connect.js +25 -0
  42. package/dist/errors.js +25 -1
  43. package/dist/handler.d.ts +61 -1
  44. package/dist/handler.js +247 -61
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.js +1 -0
  47. package/dist/keys.js +1 -4
  48. package/dist/redact.d.ts +18 -0
  49. package/dist/redact.js +29 -1
  50. package/dist/transport.d.ts +3 -1
  51. package/dist/transport.js +21 -1
  52. package/dist/types.d.ts +96 -2
  53. package/dist/util.d.ts +16 -0
  54. package/dist/util.js +50 -0
  55. package/nugget/VERSION.txt +2 -2
  56. package/nugget/dist/adapters/engines/anthropic.d.ts +14 -0
  57. package/nugget/dist/adapters/engines/anthropic.d.ts.map +1 -0
  58. package/nugget/dist/adapters/engines/anthropic.js +305 -0
  59. package/nugget/dist/adapters/engines/anthropic.js.map +1 -0
  60. package/nugget/dist/adapters/engines/base.d.ts +50 -0
  61. package/nugget/dist/adapters/engines/base.d.ts.map +1 -0
  62. package/nugget/dist/adapters/engines/base.js +137 -0
  63. package/nugget/dist/adapters/engines/base.js.map +1 -0
  64. package/nugget/dist/adapters/engines/google.d.ts +24 -0
  65. package/nugget/dist/adapters/engines/google.d.ts.map +1 -0
  66. package/nugget/dist/adapters/engines/google.js +281 -0
  67. package/nugget/dist/adapters/engines/google.js.map +1 -0
  68. package/nugget/dist/adapters/engines/ollama.d.ts +11 -0
  69. package/nugget/dist/adapters/engines/ollama.d.ts.map +1 -0
  70. package/nugget/dist/adapters/engines/ollama.js +196 -0
  71. package/nugget/dist/adapters/engines/ollama.js.map +1 -0
  72. package/nugget/dist/adapters/engines/openaiChat.d.ts +16 -0
  73. package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -0
  74. package/nugget/dist/adapters/engines/openaiChat.js +258 -0
  75. package/nugget/dist/adapters/engines/openaiChat.js.map +1 -0
  76. package/nugget/dist/adapters/index.d.ts +5 -0
  77. package/nugget/dist/adapters/index.d.ts.map +1 -0
  78. package/nugget/dist/adapters/index.js +17 -0
  79. package/nugget/dist/adapters/index.js.map +1 -0
  80. package/nugget/dist/adapters/profiles.d.ts +62 -0
  81. package/nugget/dist/adapters/profiles.d.ts.map +1 -0
  82. package/nugget/dist/adapters/profiles.js +121 -0
  83. package/nugget/dist/adapters/profiles.js.map +1 -0
  84. package/nugget/dist/agent/index.d.ts +3 -0
  85. package/nugget/dist/agent/index.d.ts.map +1 -0
  86. package/nugget/dist/agent/index.js +3 -0
  87. package/nugget/dist/agent/index.js.map +1 -0
  88. package/nugget/dist/agent/loop.d.ts +112 -0
  89. package/nugget/dist/agent/loop.d.ts.map +1 -0
  90. package/nugget/dist/agent/loop.js +370 -0
  91. package/nugget/dist/agent/loop.js.map +1 -0
  92. package/nugget/dist/agent/tools.d.ts +29 -0
  93. package/nugget/dist/agent/tools.d.ts.map +1 -0
  94. package/nugget/dist/agent/tools.js +48 -0
  95. package/nugget/dist/agent/tools.js.map +1 -0
  96. package/nugget/dist/cjs/adapters/engines/anthropic.js +308 -0
  97. package/nugget/dist/cjs/adapters/engines/base.js +147 -0
  98. package/nugget/dist/cjs/adapters/engines/google.js +284 -0
  99. package/nugget/dist/cjs/adapters/engines/ollama.js +199 -0
  100. package/nugget/dist/cjs/adapters/engines/openaiChat.js +261 -0
  101. package/nugget/dist/cjs/adapters/index.js +22 -0
  102. package/nugget/dist/cjs/adapters/profiles.js +125 -0
  103. package/nugget/dist/cjs/agent/index.js +18 -0
  104. package/nugget/dist/cjs/agent/loop.js +372 -0
  105. package/nugget/dist/cjs/agent/tools.js +51 -0
  106. package/nugget/dist/cjs/connect.js +27 -0
  107. package/nugget/dist/cjs/errors.js +110 -0
  108. package/nugget/dist/cjs/handler.js +561 -0
  109. package/nugget/dist/cjs/index.js +27 -0
  110. package/nugget/dist/cjs/json.js +121 -0
  111. package/nugget/dist/cjs/keys.js +81 -0
  112. package/nugget/dist/cjs/package.json +3 -0
  113. package/nugget/dist/cjs/policy.js +42 -0
  114. package/nugget/dist/cjs/redact.js +78 -0
  115. package/nugget/dist/cjs/tokens.js +22 -0
  116. package/nugget/dist/cjs/transport.js +172 -0
  117. package/nugget/dist/cjs/types.js +6 -0
  118. package/nugget/dist/cjs/util.js +112 -0
  119. package/nugget/dist/connect.d.ts +31 -0
  120. package/nugget/dist/connect.d.ts.map +1 -0
  121. package/nugget/dist/connect.js +25 -0
  122. package/nugget/dist/connect.js.map +1 -0
  123. package/nugget/dist/errors.d.ts +22 -0
  124. package/nugget/dist/errors.d.ts.map +1 -0
  125. package/nugget/dist/errors.js +104 -0
  126. package/nugget/dist/errors.js.map +1 -0
  127. package/nugget/dist/handler.d.ts +108 -0
  128. package/nugget/dist/handler.d.ts.map +1 -0
  129. package/nugget/dist/handler.js +557 -0
  130. package/nugget/dist/handler.js.map +1 -0
  131. package/nugget/dist/index.d.ts +12 -0
  132. package/nugget/dist/index.d.ts.map +1 -0
  133. package/nugget/dist/index.js +12 -0
  134. package/nugget/dist/index.js.map +1 -0
  135. package/nugget/dist/json.d.ts +7 -0
  136. package/nugget/dist/json.d.ts.map +1 -0
  137. package/nugget/dist/json.js +114 -0
  138. package/nugget/dist/json.js.map +1 -0
  139. package/nugget/dist/keys.d.ts +7 -0
  140. package/nugget/dist/keys.d.ts.map +1 -0
  141. package/nugget/dist/keys.js +75 -0
  142. package/nugget/dist/keys.js.map +1 -0
  143. package/nugget/dist/policy.d.ts +6 -0
  144. package/nugget/dist/policy.d.ts.map +1 -0
  145. package/nugget/dist/policy.js +37 -0
  146. package/nugget/dist/policy.js.map +1 -0
  147. package/nugget/dist/redact.d.ts +26 -0
  148. package/nugget/dist/redact.d.ts.map +1 -0
  149. package/nugget/dist/redact.js +74 -0
  150. package/nugget/dist/redact.js.map +1 -0
  151. package/nugget/dist/tokens.d.ts +5 -0
  152. package/nugget/dist/tokens.d.ts.map +1 -0
  153. package/nugget/dist/tokens.js +18 -0
  154. package/nugget/dist/tokens.js.map +1 -0
  155. package/nugget/dist/transport.d.ts +30 -0
  156. package/nugget/dist/transport.d.ts.map +1 -0
  157. package/nugget/dist/transport.js +165 -0
  158. package/nugget/dist/transport.js.map +1 -0
  159. package/nugget/dist/types.d.ts +285 -0
  160. package/nugget/dist/types.d.ts.map +1 -0
  161. package/nugget/dist/types.js +4 -0
  162. package/nugget/dist/types.js.map +1 -0
  163. package/nugget/dist/util.d.ts +24 -0
  164. package/nugget/dist/util.d.ts.map +1 -0
  165. package/nugget/dist/util.js +101 -0
  166. package/nugget/dist/util.js.map +1 -0
  167. package/nugget/src/adapters/engines/anthropic.ts +62 -37
  168. package/nugget/src/adapters/engines/base.ts +50 -7
  169. package/nugget/src/adapters/engines/google.ts +92 -29
  170. package/nugget/src/adapters/engines/ollama.ts +51 -17
  171. package/nugget/src/adapters/engines/openaiChat.ts +61 -32
  172. package/nugget/src/adapters/profiles.ts +53 -3
  173. package/nugget/src/agent/loop.ts +158 -14
  174. package/nugget/src/connect.ts +48 -0
  175. package/nugget/src/errors.ts +25 -1
  176. package/nugget/src/handler.ts +286 -59
  177. package/nugget/src/index.ts +1 -0
  178. package/nugget/src/keys.ts +1 -5
  179. package/nugget/src/redact.ts +32 -1
  180. package/nugget/src/transport.ts +20 -1
  181. package/nugget/src/types.ts +96 -1
  182. package/nugget/src/util.ts +55 -0
  183. package/package.json +20 -6
package/README.md CHANGED
@@ -1,316 +1,179 @@
1
1
  # AI Nugget
2
2
 
3
- `AI Nugget` is a small, zero-dependency, isomorphic TypeScript nugget for
4
- talking to AI model providers through one pipeline:
3
+ **Version 0.6.0** · [What's changed since 0.5.0](#whats-changed-since-050) · [Changelog](./CHANGELOG.md) · [Upgrading](./UPGRADING.md)
4
+
5
+ A small, zero-dependency, isomorphic TypeScript nugget for talking to AI model
6
+ providers through one pipeline:
5
7
 
6
8
  ```
7
9
  policy → key resolution → beforeCall hook → concurrency/retry → provider adapter → redacted telemetry
8
10
  ```
9
11
 
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.
12
+ 19 provider profiles over 4 protocol engines. Runs identically in Node, the
13
+ browser main thread, and Web Workers using only `fetch`, `ReadableStream`,
14
+ `AbortController`, and `TextDecoder`. MIT licensed.
15
+
16
+ It is intentionally **not** a router, prompt library, memory system, or secrets
17
+ vault: apps choose models, own prompts, store secrets, and decide policy.
18
+
19
+ ## Quick start
15
20
 
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/`.
21
+ Requires Node `^20.19.0` or `>=22.12.0` ([why](./docs/distribution.md#requirements)).
18
22
 
19
- ## Install / use
23
+ ```bash
24
+ npm install @jxburros/ai-nugget
25
+ ```
20
26
 
21
27
  ```ts
22
28
  import { AIHandler, envKeySource } from '@jxburros/ai-nugget';
23
29
 
24
30
  const handler = new AIHandler({ keySource: envKeySource() });
25
31
 
32
+ const conn = { id: 'main', provider: 'openai', keyRef: { kind: 'env', name: 'OPENAI_API_KEY' } };
33
+ const req = { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'Hello!' }] };
34
+
26
35
  // 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
- );
36
+ const result = await handler.chat(conn, req);
31
37
  console.log(result.text, result.usage, result.finishReason);
32
38
 
33
- // Streaming (works in Node route handlers, React, and Web Workers)
39
+ // Streaming (Node route handlers, React, Web Workers — same code)
34
40
  for await (const event of handler.stream(conn, req)) {
35
41
  if (event.type === 'delta') process.stdout.write(event.text);
36
42
  if (event.type === 'done') console.log('\n', event.result.usage);
43
+ if (event.type === 'error') console.error(event.error.kind, event.error.message);
37
44
  }
38
45
  ```
39
46
 
40
- Repos that cannot take a package dependency can vendor the generated `nugget/`
41
- folder (see below).
47
+ No key? Point at a local model instead — same code, no `keyRef`:
42
48
 
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.
49
+ ```ts
50
+ const conn = { id: 'local', provider: 'ollama', baseUrl: 'http://127.0.0.1:11434' };
51
+ ```
48
52
 
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` |
53
+ CommonJS works too: `const { AIHandler } = require('@jxburros/ai-nugget')`.
55
54
 
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.
55
+ For a running server, copy a starter from
56
+ [`examples/integrations/`](./examples/integrations/) — Express, Next.js,
57
+ Cloudflare Workers, or local Ollama.
60
58
 
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.
59
+ ## Two things to configure before production
64
60
 
65
- ### Provider capabilities
61
+ Both are seams the library leaves open on purpose, and both are easy to miss:
66
62
 
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:
63
+ 1. **Never take `provider` or `baseUrl` from client input.** They decide where
64
+ your resolved API key gets sent. Keep connections on a server-side allowlist
65
+ and let clients pick an id. → [security.md](./docs/security.md#ssrf-caller-controlled-baseurl)
66
+ 2. **Configure a `GovernancePolicy`.** With none, every provider and model is
67
+ allowed; the handler logs a startup notice so this is visible at runtime.
68
+ → [security.md](./docs/security.md#governance-is-neutral-by-default)
70
69
 
71
70
  ```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
- }
71
+ new AIHandler({
72
+ keySource: envKeySource(),
73
+ policy: allowlistPolicy({ openai: ['gpt-4o', 'gpt-4o-mini'], ollama: ['*'] }),
74
+ });
79
75
  ```
80
76
 
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
77
+ ## Documentation
78
+
79
+ | Doc | Covers |
80
+ |---|---|
81
+ | [providers.md](./docs/providers.md) | The 19 profiles, which are **live-verified vs mock-verified vs config-only**, capabilities, JSON-mode/tools limits, `openai-compat`, model discovery |
82
+ | [reliability.md](./docs/reliability.md) | `timeoutMs` vs `idleTimeoutMs`, why retries stop after output is emitted, double-billing and idempotency, why limits are per-instance |
83
+ | [security.md](./docs/security.md) | Key handling, redaction coverage, SSRF, opt-in injection defenses, the secure-tool recipe |
84
+ | [agent-loop.md](./docs/agent-loop.md) | `runAgent`, tool modes, budgets and what happens mid-step, the approval gate |
85
+ | [recipes.md](./docs/recipes.md) | Env-based connections, JSON output + validation, the error-handling matrix, telemetry |
86
+ | [integrations.md](./docs/integrations.md) | The host matrix CI runs (Node, Next.js, Workers, local runtime, packaging) |
87
+ | [distribution.md](./docs/distribution.md) | Commands, live smoke tests, CI workflows, npm/GitHub Packages/vendored `nugget/`, bundler notes |
88
+ | [design.md](./design.md) | The full contract |
89
+ | [state-of-the-nugget.md](./docs/state-of-the-nugget.md) | Current health snapshot: architecture, safety and reliability posture, coverage gaps, what's next |
90
+ | [UPGRADING.md](./UPGRADING.md) | Version-by-version changes |
91
+
92
+ ## What's in the box
93
+
94
+ - **Core** — message-based contracts (`types.ts`), typed `AIError` + `classify()`
95
+ with wire-boundary redaction, timeout/idle-timeout/abort merging and SSE/NDJSON
96
+ readers (`transport.ts`), defensive JSON extraction with schema guards
97
+ (`json.ts`), token estimation with an explicit `estimated` flag.
98
+ - **Adapters** — four engines (`openaiChat`, `anthropic`, `google`, `ollama`)
99
+ with streaming, native tool-calling, JSON modes, `finishReason` mapping,
100
+ buffered-`stream:true` fallback, reasoning-token channel, first-token latency,
101
+ usage normalization, and truncation detection (`stream_anomaly`). Provider
102
+ differences live in a data-driven profile table, not in copies of the SSE loop.
103
+ - **Pipeline (`AIHandler`)** — `chat`, `stream`, `chatParsed`, `embed`,
104
+ `listModels`, `testConnection`, `prewarm`. Every call — success, failure, or
105
+ abandoned stream — produces exactly one redacted `CallRecord`. Retries are
106
+ jittered and honor `Retry-After`; telemetry or `afterCall` failures never
107
+ re-run a completed provider call.
108
+ - **Seams** — `KeySource` (env/literal/memory/chain + ref parsing), `Redactor`,
109
+ neutral `GovernancePolicy` (`blocklistPolicy`/`allowlistPolicy`/`composePolicies`),
110
+ `TelemetrySink`, and an optional `pricing` hook. `providerOptions` passes
111
+ provider-native fields through without waiting for a library release.
112
+ - **Agent layer** (`@jxburros/ai-nugget/agent`) — `defineTool`, `runAgent()`
113
+ model↔tool loop over the full pipeline, streamed `AgentEvent`s, budgets with
114
+ honest `stopReason`s, an `ApprovalGate` for side-effecting tools, and
115
+ `native`/`promptJson`/`auto` tool modes.
116
+
117
+ ## What's changed since 0.5.0
118
+
119
+ **0.6.0 is backward-compatible** — every change below is additive. The one thing
120
+ to check is the new `not_found` error kind if you `switch` exhaustively over
121
+ `AIErrorKind`; see [UPGRADING.md](./UPGRADING.md#05x--060). Full detail lives in
122
+ the [changelog](./CHANGELOG.md).
123
+
124
+ - **Security & correctness** — `beforeCall` hooks now receive a scrubbed
125
+ connection (masked literal `keyRef` and auth headers, safe to log);
126
+ `classify()` redacts provider response excerpts at the wire boundary so an
127
+ `AIError` can never carry an unredacted secret; label-anchored redaction
128
+ covers unprefixed secrets (Azure `api-key`, AWS keys, `client_secret`, session
129
+ tokens); a new `not_found` error kind distinguishes 404/410 from a malformed
130
+ request; and Ollama tool-call arguments coerce through the shared parser so a
131
+ llama.cpp-style backend sending JSON-as-string no longer fails.
132
+ - **Observability** — `stream_anomaly` detection now fires on all four engines
133
+ (not just `openaiChat`); Google and Anthropic emit `json_mode_downgraded` when
134
+ a JSON mode is dropped for tools; `AIHandler` logs a startup notice when no
135
+ `GovernancePolicy` is configured; and OpenAI requests carry a stable
136
+ `Idempotency-Key` across retries to mitigate double-billing.
137
+ - **Providers & types** — `openai-compat` is now `keyOptional`, matching the
138
+ other local-runtime profiles, so keyless servers (including JX Runtime) work
139
+ without a `keyRef`; `Connection.provider` is typed `KnownProvider | (string &
140
+ {})` for autocomplete and typo-catching with the escape hatch still open.
141
+ - **Docs & examples** — `README.md` was trimmed to a front door with reference
142
+ material moved under [`docs/`](./docs/) (`providers`, `reliability`,
143
+ `security`, `agent-loop`, `recipes`, `integrations`, `distribution`), and
144
+ [`examples/integrations/`](./examples/integrations/) added five CI-verified
145
+ starters (Express, Next.js, Cloudflare Workers, local Ollama, ESM/CJS
146
+ packaging check).
147
+
148
+ ## Contributing
149
+
150
+ `AGENTS.md` is the always-on baseline — invariants, required reading, and the
151
+ [admission test](./AGENTS.md#feature-admission-test) a new feature has to pass.
152
+ `.claude/skills/` holds task-specific guides (`use-ai-nugget`,
153
+ `build-agent-loop`, `add-provider`, `develop-nugget`) that Claude Code loads
154
+ automatically and any agent or human can read as plain Markdown.
197
155
 
198
156
  ```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
157
+ npm test # Node contract suite
158
+ npm run test:browser # same suite in headless Chromium (isomorphism)
159
+ npm run build && npm run build:nugget
204
160
  ```
205
161
 
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.
162
+ Full command list and CI layout: [distribution.md](./docs/distribution.md).
292
163
 
293
164
  ## Examples
294
165
 
295
- `examples/` has runnable scripts against local Ollama and llama.cpp servers,
296
- `promptJson` vs. native tool-calling, an `ApprovalGate`, and telemetry — see
166
+ `examples/` has runnable scripts (local Ollama, llama.cpp, `promptJson` vs
167
+ native tools, approval gates, telemetry, a model picker), a small chat demo
168
+ (`npm run demo:steel-chat`), three mini-apps consuming the published package,
169
+ and the CI-verified [integration starters](./examples/integrations/). See
297
170
  `examples/README.md`.
298
171
 
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
172
  ## Non-goals
310
173
 
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.
174
+ Not a full agent framework (no planning/memory/RAG/multi-agent), not a
175
+ router/recommender (apps choose models), not a secrets vault (`KeySource` is a
176
+ seam), and not a UI. The governance seam ships neutral: it is where an app *can*
177
+ enforce rules, not a place the library imposes its own.
178
+
179
+ <!-- GitHub Pages deployment is configured in .github/workflows/pages.yml. -->
package/UPGRADING.md ADDED
@@ -0,0 +1,158 @@
1
+ # Upgrading
2
+
3
+ ## 0.5.x → 0.6.0
4
+
5
+ Behavior-compatible for ordinary consumers, with **two things worth checking**.
6
+
7
+ ### 1. `not_found` is a new `AIErrorKind`
8
+
9
+ 404 and 410 responses now classify as `not_found` instead of
10
+ `invalid_request`, so a wrong `baseUrl`/model or a misrouted edge is
11
+ distinguishable from a malformed request body. It is non-retryable by default.
12
+
13
+ - **If you `switch` exhaustively over `AIErrorKind`** (with a `never` default),
14
+ TypeScript will require a new branch. Map it like `auth`/`key_unavailable` —
15
+ it's a *server-side configuration* problem, not the caller's fault:
16
+
17
+ ```ts
18
+ case 'not_found': return { status: 502, message: 'The AI service is not configured correctly.' };
19
+ ```
20
+
21
+ - **If you switch non-exhaustively** with a fallback, nothing breaks: a 404 that
22
+ used to produce "that request was invalid" now falls into your default branch
23
+ instead. That is the intended improvement, but check the resulting message
24
+ still reads correctly.
25
+
26
+ ### 2. A startup notice when no policy is configured
27
+
28
+ `new AIHandler({ keySource })` with no `policy` now logs one line at
29
+ construction: every provider and model is allowed. `allowAllPolicy()` is still
30
+ the default — nothing is blocked. To silence it, say what you mean:
31
+
32
+ ```ts
33
+ new AIHandler({ keySource, policy: allowAllPolicy() }); // deliberate, no warning
34
+ new AIHandler({ keySource, silencePolicyWarning: true }); // same, if you prefer
35
+ ```
36
+
37
+ ### Also new, no action required
38
+
39
+ - **`Connection.provider` is now typed** `KnownProvider | (string & {})`. You get
40
+ autocomplete and typo-catching; arbitrary strings still compile and still
41
+ resolve through `openai-compat`.
42
+ - **`beforeCall` hooks get a scrubbed connection.** `info.connection.keyRef` is
43
+ masked for literal refs, and `info.resolved.headers` masks auth headers. If a
44
+ hook was reading `info.connection.keyRef.value` to get the key — don't; resolve
45
+ it through your `KeySource` instead.
46
+ - **`Idempotency-Key` on OpenAI requests.** One key per logical call, reused
47
+ across retries. A header you set yourself on `Connection.headers` wins.
48
+ - **New `context` stream events**: `json_mode_downgraded` (Google/Anthropic drop
49
+ JSON mode when tools are present) and `stream_anomaly` on all four engines
50
+ (previously `openaiChat` only). Both are informational; ignoring them keeps
51
+ the old behavior.
52
+ - **Documentation moved.** `README.md` is now a front door; reference material
53
+ lives under `docs/` (`providers`, `reliability`, `security`, `agent-loop`,
54
+ `recipes`, `integrations`, `distribution`). Deep links into the old README
55
+ anchors will need updating.
56
+
57
+ ## 0.4.x → 0.5.0
58
+
59
+ **0.5.0 is backward-compatible.** Every change below is additive — new optional
60
+ fields, new methods, new exports, and a new `require` entry point. No existing
61
+ signature changed and no default behavior changed, so an app on 0.4.x can bump
62
+ without code edits and adopt the new capabilities as needed.
63
+
64
+ The numbers in brackets are the suggestion numbers from the AI Server Studio
65
+ production friction report (`docs/reviews/2026-08-09-ai-server-studio-friction-report.md`),
66
+ so you can trace each change back to the friction it resolves.
67
+
68
+ ### Packaging
69
+
70
+ - **[1] Dual ESM + CommonJS build.** The package now exposes a `require`
71
+ condition (`dist/cjs/`) in addition to ESM. CommonJS hosts can
72
+ `const { AIHandler } = require('@jxburros/ai-nugget')` with no dynamic-import
73
+ shim. ESM consumers are unaffected.
74
+ - **[2] Vendorable compiled output.** `npm run build:nugget` now emits both
75
+ `nugget/src` (TypeScript) and `nugget/dist` (compiled ESM `.js` + `.d.ts`), so
76
+ bundlers that can't resolve `.ts` via `.js`-suffixed imports (e.g. Turbopack)
77
+ can vendor `nugget/dist`.
78
+
79
+ ### Requests & providers
80
+
81
+ - **[4] `ChatRequest.providerOptions`.** A provider-native passthrough merged
82
+ into the outgoing request body (shallow top-level; one level deep into Ollama
83
+ `options` and Google `generationConfig`). Reach fields the nugget doesn't
84
+ model — Ollama `num_ctx`/`keep_alive`, OpenAI `reasoning_effort`, Anthropic
85
+ `thinking`, Google `safetySettings` — without waiting for a release.
86
+ - **[16] `Connection.idleTimeoutMs`.** An idle timeout for streams, distinct from
87
+ the total `timeoutMs`: it aborts only when no chunk arrives for that long, and
88
+ resets on every chunk — so a slow-but-healthy long local generation isn't
89
+ killed mid-stream.
90
+ - **[17] `reasoning` stream events.** Reasoning/thinking tokens (Anthropic
91
+ `thinking_delta`, OpenAI `reasoning_content`, Gemini `thought` parts, Ollama
92
+ `thinking`) surface as `{ type: 'reasoning', text }` instead of being dropped
93
+ or blended into `delta`.
94
+ - **[18] `listModels()` for Anthropic & Google.** Both now query the real
95
+ endpoints (`/v1/models`, `/v1beta/models`) instead of returning `[]`.
96
+ - **[19] Faster Ollama discovery.** `/api/show` probes run with bounded
97
+ concurrency instead of serially.
98
+ - **[20] New provider profiles + Azure api-version override.** Added `cerebras`,
99
+ `moonshot`, `cohere`, and `perplexity`. The Azure `api-version` is now
100
+ overridable per call via `providerOptions.apiVersion` (default unchanged).
101
+ - **[21] Safer URL joining** in the `openai-compat` engine (no accidental double
102
+ slash even if `baseUrl` ends in `/`).
103
+ - **[23] Embeddings.** `AIHandler.embed(conn, req)` plus `EmbedRequest` /
104
+ `EmbedResult`, implemented for Ollama (`/api/embed`) and OpenAI-compatible
105
+ providers (`/embeddings`) — through the same policy/key/telemetry pipeline as
106
+ chat. Providers without embeddings throw a typed `invalid_request` error.
107
+
108
+ ### Structured output & typed results
109
+
110
+ - **[15] `AIHandler.chatParsed(conn, req, schema)`.** Requests JSON, extracts it,
111
+ and validates against any [Standard Schema](https://standardschema.dev)
112
+ validator (Zod / Valibot / ArkType) — with one automatic corrective retry on a
113
+ validation miss. No schema library is bundled; you pass your own.
114
+
115
+ ### Agent loop (`@jxburros/ai-nugget/agent`)
116
+
117
+ - **[5] Option passthrough.** `AgentOptions` now forwards `temperature`,
118
+ `maxTokens`, `topP`, `stopSequences`, and `providerOptions` to every turn.
119
+ - **[6] Capability-aware `toolMode`.** `AgentOptions.modelCapabilities` — when
120
+ `toolMode: 'auto'`, a model advertising `"tools"` uses native tool-calling even
121
+ on a local runtime whose profile default is `promptJson`.
122
+ - **[7] No leaked directives + `tool_mode` event.** In `promptJson` mode, tool
123
+ directives are withheld from the visible `delta` stream, and a `tool_mode`
124
+ event discloses the resolved protocol.
125
+ - **[8] Parameter schemas in the `promptJson` prompt**, so the model isn't
126
+ guessing argument shape.
127
+ - **[9] `undefined` tool results no longer crash a run.**
128
+ - **[10] `AgentResult.error`** is populated when `stopReason === 'error'`.
129
+ - **[11] Structured tool errors.** A tool returning `{ ok: false, ... }` is
130
+ surfaced with `isError: true` so the model can recover in-turn.
131
+ - **[12] `AgentOptions.toolResult`.** Opt-in `maxChars` cap and `wrapUntrusted`
132
+ framing for tool results re-entering context.
133
+ - **[14] `AgentOptions.approvalMode: 'all'`.** Gate every tool (not just
134
+ `sideEffects` ones) so `tool_denied` is reachable without per-tool flags.
135
+
136
+ ### Handler, telemetry & security
137
+
138
+ - **[3] `AIHandler.prewarm(conn)`.** A no-throw warm-up (health probe) to move
139
+ cold DNS/TLS/daemon-spin-up cost off the first real call.
140
+ - **[22] `CallInfo.resolved`.** The `beforeCall` hook now receives the resolved
141
+ connection (base URL + headers, key omitted) so it can validate the effective
142
+ endpoint — e.g. a request-time SSRF re-check — without hijacking `keySource`.
143
+ - **[24] `HandlerOptions.pricing`.** An optional cost estimator whose result is
144
+ recorded on `CallRecord.costUsd`.
145
+
146
+ ### Tooling & hygiene
147
+
148
+ - **[25]** Assistant messages with both content parts and tool calls no longer
149
+ drop their text/image parts on replay (Anthropic & Google); dead `role:'tool'`
150
+ branches removed; the `403 → non-retryable` policy is documented; ESLint and
151
+ Vitest coverage are wired up (`npm run lint`, `npm run test:coverage`).
152
+
153
+ ### Not changed (deliberately)
154
+
155
+ - No provider policy ships as a library default (blocklists/allowlists remain the
156
+ app's configuration at the seam).
157
+ - Zero runtime dependencies — everything above is implemented with the isomorphic
158
+ core (`fetch`/`ReadableStream`/`AbortController`/`TextDecoder`) only.