@tangleai/models 0.21.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/package.json +92 -0
- package/src/check.d.ts +29 -0
- package/src/check.js +37 -0
- package/src/client.d.ts +185 -0
- package/src/client.js +433 -0
- package/src/embed.d.ts +129 -0
- package/src/embed.js +362 -0
- package/src/embedding-vector.d.ts +9 -0
- package/src/embedding-vector.js +30 -0
- package/src/errors.d.ts +22 -0
- package/src/errors.js +45 -0
- package/src/grammar.d.ts +29 -0
- package/src/grammar.js +35 -0
- package/src/index.d.ts +9 -0
- package/src/index.js +11 -0
- package/src/providers.d.ts +69 -0
- package/src/providers.js +156 -0
- package/src/replay.d.ts +76 -0
- package/src/replay.js +141 -0
- package/src/retry.d.ts +141 -0
- package/src/retry.js +182 -0
- package/src/routing.d.ts +32 -0
- package/src/routing.js +87 -0
- package/src/sse.d.ts +1 -0
- package/src/sse.js +12 -0
- package/src/structured.d.ts +89 -0
- package/src/structured.js +219 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @tangleai/models
|
|
2
|
+
|
|
3
|
+
## 0.21.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Update the exact Jaren foundation dependencies and source pin to the published
|
|
8
|
+
0.84.3 release after verifying its AI-free archives against source-built bytes.
|
|
9
|
+
Retain Tangle's model, context and agent ownership and align the development
|
|
10
|
+
Node pin with 24.20.0. Tangle publication remains a manual author action.
|
|
11
|
+
Allow release preparation after an already committed local release while
|
|
12
|
+
preserving its record and rejecting unprepared version edits.
|
|
13
|
+
|
|
14
|
+
## 0.21.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- Add independent model transport, evidence-backed context, and bounded agent/program packages. Preserve the JavaScript/JSDoc APIs, strict result contracts and injected host services, with JavaScript distributions and checked declarations. Toolbox browser registration delegates to Jaren's shared WebMCP contract.
|
|
19
|
+
|
|
20
|
+
The source transfer is locally qualified before its first coordinated release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joham (jklarenbeek@gmail.com)
|
|
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,253 @@
|
|
|
1
|
+
# @tangleai/models
|
|
2
|
+
|
|
3
|
+
Injected model clients, provider adapters, embeddings, replay, routing and structured generation.
|
|
4
|
+
|
|
5
|
+
This package keeps its JS/JSDoc implementation and deterministic tests. Inject
|
|
6
|
+
fetch, storage and compiler services at the existing seams. The public source
|
|
7
|
+
exports and emitted npm JavaScript share one implementation.
|
|
8
|
+
|
|
9
|
+
## Public entries
|
|
10
|
+
|
|
11
|
+
- `@tangleai/models`
|
|
12
|
+
- `@tangleai/models/providers`
|
|
13
|
+
- `@tangleai/models/sse`
|
|
14
|
+
- `@tangleai/models/client`
|
|
15
|
+
- `@tangleai/models/embed`
|
|
16
|
+
- `@tangleai/models/structured`
|
|
17
|
+
- `@tangleai/models/errors`
|
|
18
|
+
- `@tangleai/models/check`
|
|
19
|
+
- `@tangleai/models/routing`
|
|
20
|
+
- `@tangleai/models/grammar`
|
|
21
|
+
- `@tangleai/models/replay`
|
|
22
|
+
- `@tangleai/models/package.json`
|
|
23
|
+
|
|
24
|
+
See [ownership and verification](../../docs/JAREN_AI_MIGRATION.md) for source
|
|
25
|
+
provenance, installation mode, unchanged serialized identities and qualification.
|
|
26
|
+
|
|
27
|
+
## The client
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { createChatClient } from '@tangleai/models/client';
|
|
31
|
+
|
|
32
|
+
const client = createChatClient({
|
|
33
|
+
provider: 'openrouter', // 'openrouter' | 'ollama' | 'lmstudio' | 'custom'
|
|
34
|
+
apiKey: userKey, // omit for local runtimes
|
|
35
|
+
model: 'qwen/qwen3-4b',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const { message } = await client.complete({
|
|
39
|
+
messages: [{ role: 'user', content: 'Say hi.' }],
|
|
40
|
+
onDelta: (text) => process.stdout.write(text), // streamed by default
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Base URLs are forgiving: `http://localhost:11434` becomes `http://localhost:11434/v1`, a
|
|
45
|
+
pasted `…/chat/completions` suffix is stripped (in any case), and a query string or
|
|
46
|
+
fragment is refused with `AI0001` rather than spliced into the middle of every endpoint.
|
|
47
|
+
The resolved base is `endpoint.base`; `/chat/completions` and `/models` are both composed
|
|
48
|
+
from it, never re-derived from one another. `fetch` is injectable
|
|
49
|
+
(`createChatClient({ fetch: myFetch })`) so the client runs identically in the browser, in
|
|
50
|
+
Node, and in tests against a scripted stub. Failures carry stable codes: `AI0001` (caller
|
|
51
|
+
error), `AI0002` (HTTP error status), `AI0003` (malformed payload).
|
|
52
|
+
|
|
53
|
+
**Token limits are configurable.** `maxTokens` sets the client's default budget;
|
|
54
|
+
`complete({ maxTokens })` overrides its amount for one call. The client option
|
|
55
|
+
`maxTokensField` selects the JSON field: `'max_tokens'` by default for existing
|
|
56
|
+
compatible providers, or `'max_completion_tokens'` for OpenAI Chat Completions.
|
|
57
|
+
The client sends exactly one of these fields when a budget is set, and neither
|
|
58
|
+
when it is unset. Selection is explicit, independent of the URL and model.
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { createChatClient } from '@tangleai/models/client';
|
|
62
|
+
|
|
63
|
+
const client = createChatClient({
|
|
64
|
+
provider: 'custom',
|
|
65
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
66
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
67
|
+
model: process.env.OPENAI_MODEL,
|
|
68
|
+
maxTokens: 3000,
|
|
69
|
+
maxTokensField: 'max_completion_tokens',
|
|
70
|
+
});
|
|
71
|
+
const reply = await client.complete({
|
|
72
|
+
messages: [{ role: 'user', content: 'Say hi.' }],
|
|
73
|
+
stream: false,
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
OpenAI's `max_completion_tokens` bounds visible output **and reasoning tokens**;
|
|
78
|
+
3,000 is their combined budget, not a promise of 3,000 visible tokens. OpenAI
|
|
79
|
+
documents `max_tokens` as deprecated and incompatible with o-series models.
|
|
80
|
+
Both parameters belong to Chat Completions; the Responses API is a different
|
|
81
|
+
endpoint. No OpenAI SDK is needed for this client. Other options still depend on
|
|
82
|
+
the selected model: `temperature` is omitted unless supplied, and `reasoning`
|
|
83
|
+
is a provider-specific passthrough, not a translation to OpenAI's
|
|
84
|
+
`reasoning_effort`. See the [OpenAI Chat Completions reference](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create).
|
|
85
|
+
|
|
86
|
+
**Retries are built in.** Transient failures — network errors, 408, 429, 5xx,
|
|
87
|
+
and malformed HTTP 200 payloads in either streaming mode — back off
|
|
88
|
+
exponentially with full jitter and try again (`retry: { attempts, baseMs, maxMs }`,
|
|
89
|
+
default 3 total tries; `attempts: 1` disables). A provider `Retry-After` header (seconds
|
|
90
|
+
or HTTP-date) overrides the computed delay, capped at `maxMs`. Two hard rules: a request
|
|
91
|
+
never retries once `onDelta` or `onReasoning` has received output, and an abort cancels the
|
|
92
|
+
backoff immediately, preserving the signal's exact cancellation reason. The final
|
|
93
|
+
transport `AI0002` reports `status`, `attempts`, and `retryAfterMs`; an exhausted
|
|
94
|
+
malformed reply reports `AI0003` with `attempts`.
|
|
95
|
+
|
|
96
|
+
**Reasoning models are first-class.** Thinking streamed as `delta.reasoning` (or
|
|
97
|
+
`reasoning_details`) reaches the caller through `onReasoning`, and the final message
|
|
98
|
+
carries a `reasoning` member — so a reasoning-only turn (empty `content`, non-empty
|
|
99
|
+
`reasoning`) is distinguishable from an empty one instead of rendering as a blank bubble.
|
|
100
|
+
The agent attaches `reasoning` to the **returned** message only: the transcript it
|
|
101
|
+
accumulates never carries it, so a host that persists `messages` and sends them back next
|
|
102
|
+
turn keeps a clean wire history.
|
|
103
|
+
|
|
104
|
+
**Thinking can be turned off** — `reasoning` is forwarded verbatim, as a client-level
|
|
105
|
+
default or per request: `createChatClient({ …, reasoning: { effort: 'none' } })`, and
|
|
106
|
+
`{ enabled: false }` does the same. (`{ exclude: true }` only HIDES the thinking; the
|
|
107
|
+
model still thinks and you still pay for it.) On a short, non-agentic call this is a large
|
|
108
|
+
win — measured on a hybrid Qwen model, a one-line answer went from 140 completion tokens
|
|
109
|
+
and 28.8s to 2 tokens and 0.5s. **Do not reach for it in an agent loop.** The same models,
|
|
110
|
+
asked to author a document through tools with thinking off, roughly doubled their tool
|
|
111
|
+
calls and stopped converging (2/3 then 0/3 runs reaching a green result, several hitting
|
|
112
|
+
the round limit): they plan the document in the reasoning channel, so removing it removes
|
|
113
|
+
the planning. Turn it off for classification, extraction and rewriting; leave it on for
|
|
114
|
+
tool use.
|
|
115
|
+
|
|
116
|
+
**A replay is a seam, and the client keys it.** `createChatClient({ …, cache })` takes
|
|
117
|
+
`{ get(key), set(key, value) }` — each sync or async, a `Map` in a test, SQLite or a
|
|
118
|
+
directory of files in a host — and answers a repeated request from it with **zero**
|
|
119
|
+
transport calls. The client builds the key, not the host: after endpoint resolution and
|
|
120
|
+
default application, from the exact credential-free body it would POST — provider,
|
|
121
|
+
normalized base, model, messages, tools, `tool_choice`, `temperature`, the selected token-limit field,
|
|
122
|
+
`reasoning`, `response_format` — canonicalized collision-free (`semanticKey` from
|
|
123
|
+
`@jarenjs/core/object`). `stream`, the signal, the callbacks and the headers never enter
|
|
124
|
+
it, and because the key is the body rather than an allow-list, an option added later
|
|
125
|
+
cannot alias an old key. A replay comes back marked `replayed: { ms }` with the
|
|
126
|
+
purchase's wall time and the purchase's `usage`, and fires `onDelta`/`onReasoning` once
|
|
127
|
+
each with the whole text, so a streaming caller sees one code path; a purchase is
|
|
128
|
+
remembered as `{ value, ms }`. The seam **fails closed**: an adapter that throws fails the
|
|
129
|
+
call, a stored entry that does not verify is `AI0003`, a request that cannot be keyed (a
|
|
130
|
+
function inside `tools`) is `AI0001` before any wire call — an adapter that wants to keep
|
|
131
|
+
buying while its storage is broken catches its own errors and answers `undefined`. The key
|
|
132
|
+
is the complete canonical request; an adapter that needs a fixed-width id hashes it with a
|
|
133
|
+
cryptographic hash, never a 32-bit one, or two prompts a token apart will one day share an
|
|
134
|
+
answer. A call ceiling counts wire calls at the injected `fetch`, which a replay never
|
|
135
|
+
reaches.
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
const store = new Map();
|
|
139
|
+
const client = createChatClient({ provider: 'ollama', model: 'qwen3:4b',
|
|
140
|
+
cache: { get: (key) => store.get(key), set: (key, value) => { store.set(key, value); } } });
|
|
141
|
+
const bought = await client.complete({ messages }); // one wire call, remembered
|
|
142
|
+
const replay = await client.complete({ messages }); // zero wire calls
|
|
143
|
+
replay.replayed; // { ms: <the purchase's wall time> }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Probe before the first turn.** `probeProvider({ provider, baseUrl, apiKey })` GETs the
|
|
147
|
+
provider's `/models` listing with exactly the auth a chat call would use and never throws:
|
|
148
|
+
`{ ok: true, models }` or `{ ok: false, status?, error }` — the contract a settings UI
|
|
149
|
+
wants for a "Test connection" button and a model picker.
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
## Embeddings
|
|
153
|
+
|
|
154
|
+
The same providers serve the OpenAI-compatible `/embeddings` wire beside `/chat/completions`
|
|
155
|
+
— OpenRouter at `/api/v1/embeddings`, Ollama and LM Studio at `/v1/embeddings` — and
|
|
156
|
+
`createEmbeddingClient` speaks it from the same resolved base, with the same key, headers,
|
|
157
|
+
`fetch` injection and retry policy as the chat client:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
import { createEmbeddingClient, probeEmbeddings } from '@tangleai/models/embed';
|
|
161
|
+
import { cosineSimilarity } from '@jarenjs/core/vector';
|
|
162
|
+
|
|
163
|
+
const embedder = createEmbeddingClient({
|
|
164
|
+
provider: 'ollama', // the chat client's providers, keys and base URLs
|
|
165
|
+
model: 'nomic-embed-text', // required — it is half of every vector's identity
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const [a, b] = await embedder.embed(['a cat on a mat', 'quarterly revenue']);
|
|
169
|
+
cosineSimilarity(a, b); // Float32Arrays in; higher is better
|
|
170
|
+
embedder.dims; // the width, settled by the first reply (or pass `dims`)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**The reply is verified, not trusted.** An `/embeddings` reply carries
|
|
174
|
+
`data: [{ index, embedding }]`, and providers do answer a batch out of order. The client
|
|
175
|
+
reassembles the items by `index` into input order and refuses the reply — `AI0003`, naming
|
|
176
|
+
the input — unless exactly one non-empty vector of finite numbers, of the expected width,
|
|
177
|
+
arrived per input. Components must stay finite after Float32 conversion, on the wire and
|
|
178
|
+
from replay; numbers that overflow that range are refused with `AI0003`.
|
|
179
|
+
An embedding attached to the wrong text is worse than an error, and this
|
|
180
|
+
is the one place in the suite that rule is enforced.
|
|
181
|
+
|
|
182
|
+
**A vector never travels without its identity.** Vectors from two models are pairwise
|
|
183
|
+
meaningless and compare into plausible garbage, so the client carries `model` and `dims`:
|
|
184
|
+
`dims` is either configured up front or fixed by the first reply, and every later reply is
|
|
185
|
+
held to it — a model that changed width under the same name is refused, never mixed.
|
|
186
|
+
|
|
187
|
+
**Retries and timeouts follow `complete()`.** Transient failures (network, 408, 429, 5xx, a
|
|
188
|
+
malformed 200) back off with the same `retry` option and the same `Retry-After` cap; an abort
|
|
189
|
+
ends everything at once. There is no default timeout, as `complete()` has none — a batch of
|
|
190
|
+
long texts on a local runtime legitimately takes a while; `timeoutMs` bounds each attempt when
|
|
191
|
+
you want one, and a timed-out attempt retries like a network failure.
|
|
192
|
+
|
|
193
|
+
**Replays are per text.** `createEmbeddingClient({ …, cache })` takes the same seam the chat
|
|
194
|
+
client documents and keys every input separately — the credential-free endpoint, the model
|
|
195
|
+
and the text — so a batch that repeats three of five texts fetches two: the remembered
|
|
196
|
+
vectors are placed, the rest travel in one wire call in input order, and every bought vector
|
|
197
|
+
is remembered as `{ vector: number[], ms }` (the batch's wall time; the array is JSON-only,
|
|
198
|
+
so a file or a SQL column stores it as it is). A call whose every text is remembered makes
|
|
199
|
+
no wire call, and its first replay settles `dims` exactly as a first reply would — a stored
|
|
200
|
+
vector of another width is `AI0003`, never mixed. Replays are observable at the seam (every
|
|
201
|
+
`set` is a purchase, every answering `get` a replay); `probeEmbeddings` never consults one.
|
|
202
|
+
|
|
203
|
+
**Probe before relying on it.** `probeEmbeddings({ provider, baseUrl, apiKey, model })` embeds
|
|
204
|
+
one word in one attempt (5 000 ms, as `probeProvider`) and never throws:
|
|
205
|
+
`{ ok: true, model, dims }` or `{ ok: false, status?, error }` — the contract for a settings
|
|
206
|
+
UI, and the live proof that a provider really serves `/embeddings`.
|
|
207
|
+
|
|
208
|
+
**The seam.** Everything in this package that consumes embeddings is written against three
|
|
209
|
+
members — `{ embed(texts, { signal }) → Promise<Float32Array[]>, model, dims }` — and anything
|
|
210
|
+
that implements them plugs in: the wire client above, a local transformer runtime, a native
|
|
211
|
+
embedding library. The contract a host implementation keeps: `embed` returns a Promise and
|
|
212
|
+
**rejects, never throws** (a synchronous throw escapes `.catch` and `Promise.all` alike — the
|
|
213
|
+
consumers here call it inside their own `try` so a host that slips is still caught, but the
|
|
214
|
+
contract is the rejection); one vector per input, in input order, all of one finite width;
|
|
215
|
+
`model` a non-empty string; `dims` the width, or `undefined` until a first reply settles it.
|
|
216
|
+
The ledger's `recall({ near })` and `embedMissing()` (§Compaction that moves) are the
|
|
217
|
+
consumers. This package ships no model weights, no tokenizer and no download, and publishes
|
|
218
|
+
no opinion on which embedding model is good; embedding *quality* belongs to the provider and
|
|
219
|
+
the host.
|
|
220
|
+
|
|
221
|
+
**The reference embedder is demo-grade, and says so.** `createHashEmbedder({ dims = 64 })`
|
|
222
|
+
implements the seam with hashed character trigrams (FNV-1a into `dims` buckets, l2-normalized)
|
|
223
|
+
— deterministic, dependency-free, network-free, identity `hash-trigram-<dims>`. It is
|
|
224
|
+
**lexical, not semantic**: two texts score high when they share letters, not when they mean the
|
|
225
|
+
same thing. It exists so that tests and offline demos exercise retrieval *mechanics* without a
|
|
226
|
+
network; it is not a substitute for a model.
|
|
227
|
+
|
|
228
|
+
The arithmetic — dot, cosine and Euclidean similarity (higher-is-better, a malformed pair
|
|
229
|
+
scores 0 and never throws), l2 normalization, the packed little-endian Float32 form and the
|
|
230
|
+
`isVector` shape guard — lives in [`@jarenjs/core/vector`](https://github.com/jklarenbeek/jarenjs/blob/main/packages/core/README.md#vectors), the
|
|
231
|
+
suite's one home for it.
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
## Structured output
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
import { createStructuredOutput } from '@tangleai/models/structured';
|
|
238
|
+
import schema from '@jarenjs/json/schemas/jaren-query.llm-profile.schema.json' with { type: 'json' };
|
|
239
|
+
|
|
240
|
+
const out = createStructuredOutput({ client, schema, name: 'jaren_query' });
|
|
241
|
+
const result = await out.generate([{ role: 'user', content: 'books over €10' }]);
|
|
242
|
+
if ('value' in result) compileJsonQuery(result.value); // validated, ready to compile
|
|
243
|
+
else console.log(result.errors); // instancePath'd, model-readable
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
One call, every provider tier: where the provider speaks
|
|
247
|
+
`response_format: json_schema` the schema constrains decoding; where it only has JSON
|
|
248
|
+
mode, or nothing, the schema travels in a system instruction. Either way the reply is
|
|
249
|
+
parsed (accidental code fences stripped) and **validated locally** by `@jarenjs/validate`
|
|
250
|
+
— the provider is an accelerator, never the authority — and a failed round goes back to
|
|
251
|
+
the model with the instancePath'd errors for a bounded number of repairs (`maxRepairs`,
|
|
252
|
+
default 1). The query/JSLT grammars ship LLM-profile twins built for exactly this
|
|
253
|
+
(see `@jarenjs/json`'s README).
|
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tangleai/models",
|
|
3
|
+
"version": "0.21.1",
|
|
4
|
+
"description": "Injected model clients, provider adapters, embeddings, replay, routing and structured generation.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"import": "./src/index.js",
|
|
13
|
+
"default": "./src/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./providers": {
|
|
16
|
+
"types": "./src/providers.d.ts",
|
|
17
|
+
"import": "./src/providers.js",
|
|
18
|
+
"default": "./src/providers.js"
|
|
19
|
+
},
|
|
20
|
+
"./sse": {
|
|
21
|
+
"types": "./src/sse.d.ts",
|
|
22
|
+
"import": "./src/sse.js",
|
|
23
|
+
"default": "./src/sse.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": {
|
|
26
|
+
"types": "./src/client.d.ts",
|
|
27
|
+
"import": "./src/client.js",
|
|
28
|
+
"default": "./src/client.js"
|
|
29
|
+
},
|
|
30
|
+
"./embed": {
|
|
31
|
+
"types": "./src/embed.d.ts",
|
|
32
|
+
"import": "./src/embed.js",
|
|
33
|
+
"default": "./src/embed.js"
|
|
34
|
+
},
|
|
35
|
+
"./structured": {
|
|
36
|
+
"types": "./src/structured.d.ts",
|
|
37
|
+
"import": "./src/structured.js",
|
|
38
|
+
"default": "./src/structured.js"
|
|
39
|
+
},
|
|
40
|
+
"./errors": {
|
|
41
|
+
"types": "./src/errors.d.ts",
|
|
42
|
+
"import": "./src/errors.js",
|
|
43
|
+
"default": "./src/errors.js"
|
|
44
|
+
},
|
|
45
|
+
"./check": {
|
|
46
|
+
"types": "./src/check.d.ts",
|
|
47
|
+
"import": "./src/check.js",
|
|
48
|
+
"default": "./src/check.js"
|
|
49
|
+
},
|
|
50
|
+
"./routing": {
|
|
51
|
+
"types": "./src/routing.d.ts",
|
|
52
|
+
"import": "./src/routing.js",
|
|
53
|
+
"default": "./src/routing.js"
|
|
54
|
+
},
|
|
55
|
+
"./grammar": {
|
|
56
|
+
"types": "./src/grammar.d.ts",
|
|
57
|
+
"import": "./src/grammar.js",
|
|
58
|
+
"default": "./src/grammar.js"
|
|
59
|
+
},
|
|
60
|
+
"./replay": {
|
|
61
|
+
"types": "./src/replay.d.ts",
|
|
62
|
+
"import": "./src/replay.js",
|
|
63
|
+
"default": "./src/replay.js"
|
|
64
|
+
},
|
|
65
|
+
"./package.json": "./package.json"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=24"
|
|
69
|
+
},
|
|
70
|
+
"sideEffects": false,
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@jarenjs/core": "0.84.3",
|
|
73
|
+
"@jarenjs/validate": "0.84.3"
|
|
74
|
+
},
|
|
75
|
+
"private": false,
|
|
76
|
+
"files": [
|
|
77
|
+
"src/**/*.js",
|
|
78
|
+
"src/**/*.d.ts",
|
|
79
|
+
"README.md",
|
|
80
|
+
"LICENSE",
|
|
81
|
+
"CHANGELOG.md"
|
|
82
|
+
],
|
|
83
|
+
"publishConfig": {
|
|
84
|
+
"access": "public",
|
|
85
|
+
"registry": "https://registry.npmjs.org/"
|
|
86
|
+
},
|
|
87
|
+
"repository": {
|
|
88
|
+
"type": "git",
|
|
89
|
+
"url": "git+https://github.com/jklarenbeek/tangleai.git",
|
|
90
|
+
"directory": "packages/models"
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/check.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rejection shape every schema-guarded boundary in this package
|
|
3
|
+
* answers with: `{ error, errors, inputSchema }`, never a throw. The
|
|
4
|
+
* model (or the caller) reads the errors, re-reads the schema, and
|
|
5
|
+
* retries — which only works if every boundary answers the same way, so
|
|
6
|
+
* this is the single implementation and both the toolbox and the ledger
|
|
7
|
+
* call it rather than each shaping its own.
|
|
8
|
+
*
|
|
9
|
+
* Member order is part of the shape: `error`, `errors`, any `extra`
|
|
10
|
+
* (the toolbox's `hint`), then `inputSchema` last, because the schema is
|
|
11
|
+
* the biggest member and a reader scans the message first.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} what - what was being validated (`add`, `memory`, …)
|
|
14
|
+
* @param {{ errors: any[] }} outcome - a normalized {@link checkOutcome}
|
|
15
|
+
* @param {any} inputSchema - the schema to re-read
|
|
16
|
+
* @param {Record<string, any>} [extra] - boundary-specific members
|
|
17
|
+
* @returns {{ error: string, errors: any[], inputSchema: any }}
|
|
18
|
+
*/
|
|
19
|
+
export function invalidInput(what: string, outcome: {
|
|
20
|
+
errors: any[];
|
|
21
|
+
}, inputSchema: any, extra?: Record<string, any>): {
|
|
22
|
+
error: string;
|
|
23
|
+
errors: any[];
|
|
24
|
+
inputSchema: any;
|
|
25
|
+
};
|
|
26
|
+
/** Model-facing validation feedback over the shared strict check contract. */
|
|
27
|
+
/** Validation errors reported back per rejected write — enough to fix
|
|
28
|
+
* from, few enough to stay readable in a model's context. */
|
|
29
|
+
export const MAX_INPUT_ERRORS: 8;
|
package/src/check.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Model-facing validation feedback over the shared strict check contract. */
|
|
3
|
+
|
|
4
|
+
/** Validation errors reported back per rejected write — enough to fix
|
|
5
|
+
* from, few enough to stay readable in a model's context. */
|
|
6
|
+
export const MAX_INPUT_ERRORS = 8;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one rejection shape every schema-guarded boundary in this package
|
|
10
|
+
* answers with: `{ error, errors, inputSchema }`, never a throw. The
|
|
11
|
+
* model (or the caller) reads the errors, re-reads the schema, and
|
|
12
|
+
* retries — which only works if every boundary answers the same way, so
|
|
13
|
+
* this is the single implementation and both the toolbox and the ledger
|
|
14
|
+
* call it rather than each shaping its own.
|
|
15
|
+
*
|
|
16
|
+
* Member order is part of the shape: `error`, `errors`, any `extra`
|
|
17
|
+
* (the toolbox's `hint`), then `inputSchema` last, because the schema is
|
|
18
|
+
* the biggest member and a reader scans the message first.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} what - what was being validated (`add`, `memory`, …)
|
|
21
|
+
* @param {{ errors: any[] }} outcome - a normalized {@link checkOutcome}
|
|
22
|
+
* @param {any} inputSchema - the schema to re-read
|
|
23
|
+
* @param {Record<string, any>} [extra] - boundary-specific members
|
|
24
|
+
* @returns {{ error: string, errors: any[], inputSchema: any }}
|
|
25
|
+
*/
|
|
26
|
+
export function invalidInput(what, outcome, inputSchema, extra = {}) {
|
|
27
|
+
return {
|
|
28
|
+
error: `invalid input for ${what}`,
|
|
29
|
+
errors: outcome.errors.slice(0, MAX_INPUT_ERRORS).map((e) => ({
|
|
30
|
+
instancePath: e.instancePath ?? '',
|
|
31
|
+
keyword: e.keyword ?? '',
|
|
32
|
+
message: e.message ?? 'invalid',
|
|
33
|
+
})),
|
|
34
|
+
...extra,
|
|
35
|
+
inputSchema,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/client.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reasoning text one streamed chunk carries: the OpenRouter/`o`-
|
|
3
|
+
* family `delta.reasoning` string, or the `reasoning_details` text
|
|
4
|
+
* entries some providers emit instead.
|
|
5
|
+
* @param {any} delta
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function reasoningOf(delta: any): string;
|
|
9
|
+
/**
|
|
10
|
+
* Accumulates OpenAI streaming chunks (`choices[0].delta`) into one
|
|
11
|
+
* normalized assistant message. Tool-call fragments merge by `index`;
|
|
12
|
+
* argument strings concatenate across chunks; reasoning deltas
|
|
13
|
+
* accumulate into `message.reasoning` (absent when the model emitted
|
|
14
|
+
* none) so a reasoning-only turn is distinguishable from an empty one.
|
|
15
|
+
* @returns {{ push: (chunk: any) => string, result: () => any }}
|
|
16
|
+
* `push` returns the text delta this chunk contributed (may be '').
|
|
17
|
+
*/
|
|
18
|
+
export function createStreamAccumulator(): {
|
|
19
|
+
push: (chunk: any) => string;
|
|
20
|
+
result: () => any;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} ChatRequest
|
|
24
|
+
* @property {any[]} messages - OpenAI wire-shape messages
|
|
25
|
+
* @property {any[]} [tools] - OpenAI function-tool definitions
|
|
26
|
+
* @property {any} [toolChoice] - `tool_choice` passthrough
|
|
27
|
+
* @property {string} [model] - overrides the client's configured model
|
|
28
|
+
* @property {number} [temperature]
|
|
29
|
+
* @property {number} [maxTokens] - token ceiling for this reply, sent under
|
|
30
|
+
* the client's `maxTokensField`. Overrides the client default; when
|
|
31
|
+
* both are unset, the provider chooses the limit. With
|
|
32
|
+
* `max_completion_tokens`, reasoning tokens share this budget with
|
|
33
|
+
* visible output tokens.
|
|
34
|
+
* @property {boolean} [stream] - default true
|
|
35
|
+
* @property {{ name?: string, schema?: any, strict?: boolean, type?: 'json' }} [responseFormat]
|
|
36
|
+
* - structured output: `{ name, schema, strict? }` emits the OpenAI
|
|
37
|
+
* `response_format: { type: "json_schema", … }` wire shape (strict
|
|
38
|
+
* defaults to true); `{ type: 'json' }` emits `json_object` mode
|
|
39
|
+
* @property {{ effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high',
|
|
40
|
+
* enabled?: boolean, exclude?: boolean, max_tokens?: number }} [reasoning]
|
|
41
|
+
* - the provider-normalized thinking control, forwarded verbatim.
|
|
42
|
+
* `{ effort: 'none' }` (or `{ enabled: false }`) turns a hybrid
|
|
43
|
+
* thinking model OFF: it answers directly, which on a short task is
|
|
44
|
+
* dramatically cheaper and faster. `{ exclude: true }` only HIDES the
|
|
45
|
+
* thinking — the model still thinks and you still pay for it.
|
|
46
|
+
* Overrides the client-level default.
|
|
47
|
+
* @property {AbortSignal} [signal]
|
|
48
|
+
* @property {(text: string) => void} [onDelta] - streamed text callback
|
|
49
|
+
* @property {(text: string) => void} [onReasoning] - streamed reasoning
|
|
50
|
+
* callback (reasoning models emit thinking before/instead of content)
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* @param {{ provider?: string, baseUrl?: string, apiKey?: string,
|
|
54
|
+
* model?: string, headers?: Record<string, string>,
|
|
55
|
+
* fetch?: typeof fetch, maxTokens?: number,
|
|
56
|
+
* maxTokensField?: 'max_tokens' | 'max_completion_tokens',
|
|
57
|
+
* reasoning?: { effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high',
|
|
58
|
+
* enabled?: boolean, exclude?: boolean, max_tokens?: number },
|
|
59
|
+
* retry?: import('./retry.js').RetryOptions,
|
|
60
|
+
* cache?: import('./replay.js').ReplayCache }} [options]
|
|
61
|
+
* - `maxTokensField` selects the wire field for client and request
|
|
62
|
+
* `maxTokens` budgets (default `'max_tokens'`). Select
|
|
63
|
+
* `'max_completion_tokens'` for OpenAI Chat Completions, including
|
|
64
|
+
* reasoning models. The selection is explicit, not inferred from
|
|
65
|
+
* the URL or model; exactly one field is sent when a budget is set.
|
|
66
|
+
* - `reasoning` is the default thinking control for every request (see
|
|
67
|
+
* `ChatRequest.reasoning`); a per-request value overrides it.
|
|
68
|
+
* - `cache` is the replay seam: `{ get(key), set(key, value) }`, each
|
|
69
|
+
* sync or async. The client keys every request by its effective
|
|
70
|
+
* credential-free wire body after endpoint resolution and default
|
|
71
|
+
* application (`stream`, the signal and the callbacks never enter
|
|
72
|
+
* the key), answers a remembered reply with ZERO transport calls,
|
|
73
|
+
* marked `replayed: { ms }` — the wall time of the purchase — with
|
|
74
|
+
* `onDelta`/`onReasoning` fired once each with the whole text, and
|
|
75
|
+
* remembers a bought reply as `{ value, ms }`. The seam fails closed:
|
|
76
|
+
* an adapter that throws fails the call; a stored entry that does not
|
|
77
|
+
* verify is `AI0003`; a request that cannot be keyed (a function
|
|
78
|
+
* inside `tools`) is `AI0001` before any wire call. The key is the
|
|
79
|
+
* complete canonical request — an adapter wanting a fixed-width id
|
|
80
|
+
* hashes it cryptographically, never with a 32-bit hash.
|
|
81
|
+
* - `retry.attempts` is the TOTAL number of tries (default 3; 1
|
|
82
|
+
* disables retrying); backoff is exponential with full jitter,
|
|
83
|
+
* capped at `maxMs`. A provider `Retry-After` (seconds or HTTP-date)
|
|
84
|
+
* wins over the computed delay, capped at `maxMs` too: a provider
|
|
85
|
+
* asking for a minute gets the cap (8 000 ms by default), and the
|
|
86
|
+
* value it asked for rides the final error as `retryAfterMs` for
|
|
87
|
+
* the caller to honour. `random` and `sleep` exist for deterministic
|
|
88
|
+
* tests.
|
|
89
|
+
* @returns {{ endpoint: { provider: string, base: string, url: string,
|
|
90
|
+
* headers: Record<string, string>, model: string },
|
|
91
|
+
* complete: (request: ChatRequest) => Promise<any> }}
|
|
92
|
+
*/
|
|
93
|
+
export function createChatClient(options?: {
|
|
94
|
+
provider?: string;
|
|
95
|
+
baseUrl?: string;
|
|
96
|
+
apiKey?: string;
|
|
97
|
+
model?: string;
|
|
98
|
+
headers?: Record<string, string>;
|
|
99
|
+
fetch?: typeof fetch;
|
|
100
|
+
maxTokens?: number;
|
|
101
|
+
maxTokensField?: "max_tokens" | "max_completion_tokens";
|
|
102
|
+
reasoning?: {
|
|
103
|
+
effort?: "none" | "minimal" | "low" | "medium" | "high";
|
|
104
|
+
enabled?: boolean;
|
|
105
|
+
exclude?: boolean;
|
|
106
|
+
max_tokens?: number;
|
|
107
|
+
};
|
|
108
|
+
retry?: import("./retry.js").RetryOptions;
|
|
109
|
+
cache?: import("./replay.js").ReplayCache;
|
|
110
|
+
}): {
|
|
111
|
+
endpoint: {
|
|
112
|
+
provider: string;
|
|
113
|
+
base: string;
|
|
114
|
+
url: string;
|
|
115
|
+
headers: Record<string, string>;
|
|
116
|
+
model: string;
|
|
117
|
+
};
|
|
118
|
+
complete: (request: ChatRequest) => Promise<any>;
|
|
119
|
+
};
|
|
120
|
+
export type ChatRequest = {
|
|
121
|
+
/**
|
|
122
|
+
* - OpenAI wire-shape messages
|
|
123
|
+
*/
|
|
124
|
+
messages: any[];
|
|
125
|
+
/**
|
|
126
|
+
* - OpenAI function-tool definitions
|
|
127
|
+
*/
|
|
128
|
+
tools?: any[];
|
|
129
|
+
/**
|
|
130
|
+
* - `tool_choice` passthrough
|
|
131
|
+
*/
|
|
132
|
+
toolChoice?: any;
|
|
133
|
+
/**
|
|
134
|
+
* - overrides the client's configured model
|
|
135
|
+
*/
|
|
136
|
+
model?: string;
|
|
137
|
+
temperature?: number;
|
|
138
|
+
/**
|
|
139
|
+
* - token ceiling for this reply, sent under
|
|
140
|
+
* the client's `maxTokensField`. Overrides the client default; when
|
|
141
|
+
* both are unset, the provider chooses the limit. With
|
|
142
|
+
* `max_completion_tokens`, reasoning tokens share this budget with
|
|
143
|
+
* visible output tokens.
|
|
144
|
+
*/
|
|
145
|
+
maxTokens?: number;
|
|
146
|
+
/**
|
|
147
|
+
* - default true
|
|
148
|
+
*/
|
|
149
|
+
stream?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* - structured output: `{ name, schema, strict? }` emits the OpenAI
|
|
152
|
+
* `response_format: { type: "json_schema", … }` wire shape (strict
|
|
153
|
+
* defaults to true); `{ type: 'json' }` emits `json_object` mode
|
|
154
|
+
*/
|
|
155
|
+
responseFormat?: {
|
|
156
|
+
name?: string;
|
|
157
|
+
schema?: any;
|
|
158
|
+
strict?: boolean;
|
|
159
|
+
type?: "json";
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* - the provider-normalized thinking control, forwarded verbatim.
|
|
163
|
+
* `{ effort: 'none' }` (or `{ enabled: false }`) turns a hybrid
|
|
164
|
+
* thinking model OFF: it answers directly, which on a short task is
|
|
165
|
+
* dramatically cheaper and faster. `{ exclude: true }` only HIDES the
|
|
166
|
+
* thinking — the model still thinks and you still pay for it.
|
|
167
|
+
* Overrides the client-level default.
|
|
168
|
+
*/
|
|
169
|
+
reasoning?: {
|
|
170
|
+
effort?: "none" | "minimal" | "low" | "medium" | "high";
|
|
171
|
+
enabled?: boolean;
|
|
172
|
+
exclude?: boolean;
|
|
173
|
+
max_tokens?: number;
|
|
174
|
+
};
|
|
175
|
+
signal?: AbortSignal;
|
|
176
|
+
/**
|
|
177
|
+
* - streamed text callback
|
|
178
|
+
*/
|
|
179
|
+
onDelta?: (text: string) => void;
|
|
180
|
+
/**
|
|
181
|
+
* - streamed reasoning
|
|
182
|
+
* callback (reasoning models emit thinking before/instead of content)
|
|
183
|
+
*/
|
|
184
|
+
onReasoning?: (text: string) => void;
|
|
185
|
+
};
|