@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 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
+ }
@@ -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
+ };