modelpact-providers 1.0.0 → 1.1.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.
package/README.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # modelpact-providers
2
2
 
3
- Language-model backends written against the modelpact contract — one transport per runtime, each green on the contract suite
3
+ [![npm](https://img.shields.io/npm/v/modelpact-providers)](https://www.npmjs.com/package/modelpact-providers)
4
+ [![ci](https://github.com/AvdienkoSergey/modelpact-providers/actions/workflows/ci.yml/badge.svg?event=pull_request)](https://github.com/AvdienkoSergey/modelpact-providers/actions/workflows/ci.yml)
5
+ ![node: ≥22](https://img.shields.io/badge/node-%E2%89%A522-339933)
6
+ [![license: MIT](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE)
7
+
8
+ **The transports for [modelpact](https://github.com/AvdienkoSergey/modelpact):
9
+ a daemon on your machine, the model inside Chrome, and a model in the tab
10
+ itself. Three backends, one dialect, and the contract suite green on each.**
11
+
12
+ The engine holds the contract, the lifecycle and one backend with nothing
13
+ behind it. This package holds the ones with something behind them. They are
14
+ apart because they change for different reasons: a daemon's JSON, a browser's
15
+ origin trial and a WebGPU runtime each move on their own clock, and none of
16
+ them should move the contract.
4
17
 
5
18
  ## Install
6
19
 
@@ -8,19 +21,122 @@ Language-model backends written against the modelpact contract — one transport
8
21
  npm install modelpact-providers modelpact
9
22
  ```
10
23
 
11
- `modelpact` is a peer dependency: this package is built against its contract
12
- and does not carry a copy of it.
24
+ `modelpact` is a peer dependency: this package is written against its contract
25
+ and carries no copy of it.
26
+
27
+ ## The three
28
+
29
+ | Provider | Reaches | Wants | Import from |
30
+ | ----------------------- | --------------------------------- | --------------------------------------- | ---------------------------- |
31
+ | `makeOllamaProvider` | a daemon over HTTP, usually local | Ollama on `127.0.0.1:11434` | `modelpact-providers` |
32
+ | `makePromptApiProvider` | Chrome's built-in Gemini Nano | Chrome, and the weights downloaded once | `modelpact-providers` |
33
+ | `makeWebGpuProvider` | a model in the tab, on WebGPU | `@mlc-ai/web-llm`, and a GPU | `modelpact-providers/webgpu` |
34
+
35
+ ```ts
36
+ import { makeOllamaProvider } from "modelpact-providers";
37
+
38
+ const access = await makeOllamaProvider({ model: "granite4:350m" }).access();
39
+ if (access.kind !== "ready") return;
40
+ const opened = await access.open({ system: "Answer in one sentence." });
41
+ ```
42
+
43
+ Everything after the provider line is the contract's, and identical across the
44
+ three — see [modelpact's README](https://github.com/AvdienkoSergey/modelpact#readme)
45
+ for what a session promises.
46
+
47
+ ## Two entries, and the reason
48
+
49
+ `modelpact-providers` is the two that cost a consumer nothing: `fetch` and JSON
50
+ for the daemon, a global for the browser's own model, no runtime dependency
51
+ behind either. `modelpact-providers/webgpu` is the third, because it carries
52
+ `@mlc-ai/web-llm` — an optional peer dependency, so an app on the daemon never
53
+ installs it. The split is by dependency, not by kind: the same reason
54
+ `modelpact/testing` is a separate entry for `vitest`.
55
+
56
+ ## What each one is
57
+
58
+ **Ollama.** Three endpoints are the whole backend — `/api/tags` says what is
59
+ downloaded, `/api/pull` downloads, `/api/chat` generates. Shapes were read off
60
+ a running daemon, not off the docs: a chat stream is NDJSON whose last line
61
+ carries the counts, a pull line carries `completed` and `total` per layer, and
62
+ an error is an HTTP status with a body. The daemon keeps nothing between
63
+ requests, so the session's record is resent whole every turn.
64
+
65
+ **Chrome's built-in model.** The one backend that keeps the conversation
66
+ itself: `LanguageModel` is a session object and `prompt()` appends to it, so
67
+ the record travels with the session rather than in the request. It fires its
68
+ own `contextoverflow`, which is forwarded rather than re-derived, and reports
69
+ usage against a window it decides — 9 216 tokens on Chrome 152, measured.
70
+ The declarations are `@types/dom-chromium-ai`, patched under
71
+ [`patches/`](patches) because the IDL is looser than the spec: several states
72
+ the algorithm rejects at runtime are writable in the types, and a TS error at
73
+ the keyboard beats a `TypeError` in the browser.
74
+
75
+ **WebGPU.** A model in the tab through `@mlc-ai/web-llm`, and the only backend
76
+ whose download costs the user their bandwidth rather than a daemon's. It was
77
+ written before this package existed, in a directory one repository over, to
78
+ answer one question: is the published API enough to write a backend with. It
79
+ was, and it needed nothing added to the contract.
80
+
81
+ ## Tools
82
+
83
+ All three accept `ModelRequest.tools`, and each executes them the way its
84
+ transport can.
85
+
86
+ | Provider | How a call happens |
87
+ | ---------- | ------------------------------------------------------------------------------------------------- |
88
+ | Ollama | native `tool_calls`, answered under the `tool` role, in rounds bounded by `maxToolRounds` |
89
+ | Prompt API | handed to `create()`, and the browser calls `execute` itself |
90
+ | WebGPU | not yet: the request is refused at `access`, which is the contract's answer for a backend without |
91
+
92
+ Chrome 152 answers `available` to `availability()` with tools and then throws
93
+ `InvalidStateError` from `create()` — measured, and it arrives as a refusal at
94
+ `open`. A loop above should expect a refusal in both places and fall back to a
95
+ schema-constrained answer.
96
+
97
+ ## The guard
98
+
99
+ [`src/surface.ts`](src/surface.ts) names every published type on both sides —
100
+ the engine's and this package's — and
101
+ [`tsconfig.surface.json`](tsconfig.surface.json) compiles it with
102
+ `skipLibCheck` off and `types: []`, which is how a consumer reads a `.d.ts`. A
103
+ declaration that needs an ambient global fails there and nowhere else.
104
+
105
+ That guard has found the same bug three times, in three packages. A published
106
+ type naming `LanguageModel` broke a consumer who never installed
107
+ `@types/dom-chromium-ai`. A `WebGpuConfig.engine` typed with
108
+ `MLCEngineInterface` broke one the same way, through `@mlc-ai/web-llm`'s own
109
+ declarations, which name packages they do not depend on. Both fixes are the
110
+ same: a structural type of exactly what the backend uses, named locally, and no
111
+ third-party type in any exported signature.
112
+
113
+ Moving here found a third. `@mlc-ai/web-llm`'s `interruptGenerate()` returns a
114
+ promise, though its own published interface says it returns nothing; the
115
+ adapter called it and dropped that promise on the floor. It is marked `void`
116
+ now, deliberately, because by the time it runs the lifecycle has already
117
+ answered the caller.
118
+
119
+ Three tsconfigs, and each has one job:
120
+
121
+ | File | Checks |
122
+ | ------------------------------------------------ | -------------------------------------------------------------------------- |
123
+ | [`tsconfig.json`](tsconfig.json) | the source. `skipLibCheck` on, because `@mlc-ai/web-llm` cannot survive it |
124
+ | [`tsconfig.patched.json`](tsconfig.patched.json) | everything but WebGPU, with `skipLibCheck` off — the patch, still applying |
125
+ | [`tsconfig.surface.json`](tsconfig.surface.json) | the emitted declarations, as a consumer receives them |
13
126
 
14
127
  ## Scripts
15
128
 
16
- | Script | What it does |
17
- | ---------------------- | -------------------------------- |
18
- | `npm run typecheck` | `tsc --noEmit` over `src` |
19
- | `npm run lint` | ESLint, type-aware |
20
- | `npm run format:check` | Prettier, check only |
21
- | `npm test` | Vitest, once |
22
- | `npm run test:watch` | Vitest, watching |
23
- | `npm run build` | `dist/` — JS, declarations, maps |
129
+ | Script | What it does |
130
+ | ----------------------- | ------------------------------------------------------ |
131
+ | `npm run typecheck` | both source configs |
132
+ | `npm run lint` | ESLint, type-aware |
133
+ | `npm run format:check` | Prettier, check only |
134
+ | `npm test` | Vitest; the Ollama contract suite skips with no daemon |
135
+ | `npm run check:surface` | builds, then reads the declarations from outside |
136
+ | `npm run build` | `dist/` — JS, declarations, maps |
137
+
138
+ The Ollama suite wants a daemon on `127.0.0.1:11434` holding `granite4:350m`.
139
+ Without one it skips loudly rather than passing quietly.
24
140
 
25
141
  ## Releases
26
142
 
package/dist/index.d.ts CHANGED
@@ -1,2 +1,12 @@
1
- export {};
1
+ /**
2
+ * The transports that cost a consumer nothing but this package: `fetch` and
3
+ * JSON for the daemon, a global for the browser's own model, no runtime
4
+ * dependency behind either. Import one and a bundler drops the other.
5
+ *
6
+ * The WebGPU backend is `modelpact-providers/webgpu` instead, because it
7
+ * carries `@mlc-ai/web-llm` — a separate entry for a separate dependency, the
8
+ * way `modelpact/testing` is separate for `vitest`.
9
+ */
10
+ export { makeOllamaProvider, type OllamaConfig } from "./ollama.js";
11
+ export { makePromptApiProvider } from "./prompt-api.js";
2
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,kBAAkB,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,12 @@
1
- export {};
1
+ /**
2
+ * The transports that cost a consumer nothing but this package: `fetch` and
3
+ * JSON for the daemon, a global for the browser's own model, no runtime
4
+ * dependency behind either. Import one and a bundler drops the other.
5
+ *
6
+ * The WebGPU backend is `modelpact-providers/webgpu` instead, because it
7
+ * carries `@mlc-ai/web-llm` — a separate entry for a separate dependency, the
8
+ * way `modelpact/testing` is separate for `vitest`.
9
+ */
10
+ export { makeOllamaProvider } from "./ollama.js";
11
+ export { makePromptApiProvider } from "./prompt-api.js";
2
12
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,kBAAkB,EAAqB,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Ollama on a machine you can reach over HTTP.
3
+ *
4
+ * The daemon keeps nothing between requests: `/api/chat` is handed the whole
5
+ * conversation every time, which is what `request.history` is for. Three
6
+ * endpoints are the whole backend — `/api/tags` says what is downloaded,
7
+ * `/api/pull` downloads, `/api/chat` generates — and everything else the
8
+ * contract promises is the lifecycle's, back in `modelpact`.
9
+ *
10
+ * Shapes here were read off a running daemon, not off the docs: a chat stream
11
+ * is NDJSON whose last line carries the counts, a pull line carries `completed`
12
+ * and `total` per layer, and an error is an HTTP status with `{"error": "…"}`.
13
+ */
14
+ import { type AiProvider } from "modelpact/backend";
15
+ export interface OllamaConfig {
16
+ /** The tag as `/api/tags` lists it, such as `granite4:350m`. */
17
+ readonly model: string;
18
+ /**
19
+ * `127.0.0.1` and not `localhost`: the daemon binds the one, and the name
20
+ * can resolve to the other family first and refuse the connection.
21
+ */
22
+ readonly host?: string;
23
+ /**
24
+ * Sent as `num_ctx`, and therefore the window in force rather than a guess
25
+ * at one. The default matches the daemon's own; a model that can take more
26
+ * will, at the price of the memory the cache for it costs.
27
+ */
28
+ readonly contextWindow?: number;
29
+ /** For a proxy, an auth header, or a test with no daemon behind it. */
30
+ readonly fetch?: typeof globalThis.fetch;
31
+ /**
32
+ * How many times one turn may come back with tool calls before it is failed.
33
+ * Per turn, not per session: a model that keeps asking spends the window on
34
+ * its own questions and never answers.
35
+ */
36
+ readonly maxToolRounds?: number;
37
+ }
38
+ export declare function makeOllamaProvider(config: OllamaConfig): AiProvider;
39
+ //# sourceMappingURL=ollama.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ollama.d.ts","sourceRoot":"","sources":["../src/ollama.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAaL,KAAK,UAAU,EAUhB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,YAAY;IAC3B,gEAAgE;IAChE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IACzC;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAsiBD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG,UAAU,CASnE"}
package/dist/ollama.js ADDED
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Ollama on a machine you can reach over HTTP.
3
+ *
4
+ * The daemon keeps nothing between requests: `/api/chat` is handed the whole
5
+ * conversation every time, which is what `request.history` is for. Three
6
+ * endpoints are the whole backend — `/api/tags` says what is downloaded,
7
+ * `/api/pull` downloads, `/api/chat` generates — and everything else the
8
+ * contract promises is the lifecycle's, back in `modelpact`.
9
+ *
10
+ * Shapes here were read off a running daemon, not off the docs: a chat stream
11
+ * is NDJSON whose last line carries the counts, a pull line carries `completed`
12
+ * and `total` per layer, and an error is an HTTP status with `{"error": "…"}`.
13
+ */
14
+ import { AiError, contextUsage, createProvider, err, findTool, fraction, ndjsonLines, ok, runTool, tokens, } from "modelpact/backend";
15
+ const DEFAULTS = {
16
+ host: "http://127.0.0.1:11434",
17
+ contextWindow: 4096,
18
+ maxToolRounds: 8,
19
+ };
20
+ const asRecord = (value) => {
21
+ const isObject = typeof value === "object" && value !== null;
22
+ return isObject && !Array.isArray(value)
23
+ ? value
24
+ : null;
25
+ };
26
+ const asString = (value) => typeof value === "string" ? value : null;
27
+ const asNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
28
+ const parseJson = (text) => {
29
+ try {
30
+ return JSON.parse(text);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ };
36
+ const toEndpoint = (config) => ({
37
+ host: config.host ?? DEFAULTS.host,
38
+ // Bound, and not optional: in a browser `fetch` is a method of the window and
39
+ // throws `TypeError: Illegal invocation` once it is held on its own. Node
40
+ // does not care, so nothing but a page catches this (measured).
41
+ call: config.fetch ?? globalThis.fetch.bind(globalThis),
42
+ });
43
+ const postTo = (endpoint, path, body, signal) => endpoint.call(`${endpoint.host}${path}`, {
44
+ method: "POST",
45
+ headers: { "content-type": "application/json" },
46
+ body: JSON.stringify(body),
47
+ ...(signal === undefined ? {} : { signal }),
48
+ });
49
+ /** The daemon says what went wrong in the body; a status alone would lose it. */
50
+ const failureFromResponse = async (response) => {
51
+ const text = await response.text().catch(() => "");
52
+ const errorText = asString(asRecord(parseJson(text))?.error);
53
+ const detail = errorText ?? `${response.status} from the daemon`;
54
+ // 400 is the daemon reading the request and refusing it, which is a bug on
55
+ // this side. Everything else is the daemon's own trouble.
56
+ return response.status === 400
57
+ ? { kind: "invalid-input", detail }
58
+ : { kind: "failed", detail };
59
+ };
60
+ const listModels = async (endpoint) => {
61
+ const response = await endpoint.call(`${endpoint.host}/api/tags`);
62
+ if (!response.ok)
63
+ return [];
64
+ const listedModels = asRecord(await response.json().catch(() => null))?.models;
65
+ if (!Array.isArray(listedModels))
66
+ return [];
67
+ const names = listedModels.map((entry) => asString(asRecord(entry)?.model));
68
+ return names.filter((name) => name !== null);
69
+ };
70
+ const getAvailability = async (config) => {
71
+ const endpoint = toEndpoint(config);
72
+ let downloadedModels;
73
+ try {
74
+ downloadedModels = await listModels(endpoint);
75
+ }
76
+ catch (cause) {
77
+ // Nothing answering is not a failed request, it is no Ollama here.
78
+ return { kind: "unavailable", reason: { kind: "unsupported", cause } };
79
+ }
80
+ const isDownloaded = downloadedModels.includes(config.model);
81
+ return isDownloaded
82
+ ? { kind: "ready" }
83
+ : { kind: "needs-download", started: false };
84
+ };
85
+ /**
86
+ * Pull progress is per layer, and layers are announced as the pull reaches
87
+ * them, so the denominator grows while it runs. The share can therefore stall,
88
+ * and `ProgressMonitor` drops it if it would step back. `success` is what
89
+ * makes the last report a 1, since that line carries no numbers.
90
+ */
91
+ const readPullProgress = (reportProgress) => {
92
+ const layers = new Map();
93
+ return new TransformStream({
94
+ transform: (line, controller) => {
95
+ const parsedLine = asRecord(parseJson(line));
96
+ if (parsedLine === null)
97
+ return;
98
+ const errorText = asString(parsedLine.error);
99
+ if (errorText !== null)
100
+ throw new AiError({ kind: "failed", detail: errorText });
101
+ const digest = asString(parsedLine.digest);
102
+ const total = asNumber(parsedLine.total);
103
+ const completed = asNumber(parsedLine.completed) ?? 0;
104
+ if (digest !== null && total !== null && total > 0) {
105
+ layers.set(digest, { total, completed });
106
+ }
107
+ const isDone = asString(parsedLine.status) === "success";
108
+ const pulledShare = isDone ? 1 : getPulledShare(layers);
109
+ const progress = fraction(pulledShare);
110
+ if (progress !== null)
111
+ reportProgress(progress);
112
+ controller.enqueue(line);
113
+ },
114
+ });
115
+ };
116
+ const getPulledShare = (layers) => {
117
+ let total = 0;
118
+ let completed = 0;
119
+ for (const layer of layers.values()) {
120
+ total += layer.total;
121
+ completed += layer.completed;
122
+ }
123
+ return total === 0 ? 0 : completed / total;
124
+ };
125
+ const pullModel = async (endpoint, model, reportProgress) => {
126
+ const response = await postTo(endpoint, "/api/pull", { model, stream: true });
127
+ if (!response.ok)
128
+ return err(await failureFromResponse(response));
129
+ if (response.body === null)
130
+ return err({ kind: "failed", detail: "the pull sent no body" });
131
+ const lines = response.body
132
+ .pipeThrough(new TextDecoderStream())
133
+ .pipeThrough(ndjsonLines())
134
+ .pipeThrough(readPullProgress(reportProgress));
135
+ const reader = lines.getReader();
136
+ try {
137
+ // Read to the end: the transform above is where the reporting happens, and
138
+ // the body is not finished until it stops yielding.
139
+ for (;;) {
140
+ const chunk = await reader.read();
141
+ if (chunk.done)
142
+ return ok(null);
143
+ }
144
+ }
145
+ catch (error) {
146
+ return err(error instanceof AiError
147
+ ? error.failure
148
+ : { kind: "failed", detail: "the pull was interrupted", cause: error });
149
+ }
150
+ };
151
+ const toOllamaTools = (tools) => tools.map((tool) => ({
152
+ type: "function",
153
+ function: {
154
+ name: tool.name,
155
+ description: tool.description,
156
+ parameters: tool.inputSchema,
157
+ },
158
+ }));
159
+ /** A call carries `function.name` and `function.arguments`, an object; one without a name is skipped. */
160
+ const readToolCalls = (message) => {
161
+ const listedCalls = message?.tool_calls;
162
+ if (!Array.isArray(listedCalls))
163
+ return [];
164
+ const readCalls = [];
165
+ for (const listedCall of listedCalls) {
166
+ const calledFunction = asRecord(asRecord(listedCall)?.function);
167
+ const name = asString(calledFunction?.name);
168
+ if (name === null)
169
+ continue;
170
+ const callArguments = asRecord(calledFunction?.arguments) ?? {};
171
+ readCalls.push({ function: { name, arguments: callArguments } });
172
+ }
173
+ return readCalls;
174
+ };
175
+ const readWhole = async (stream) => {
176
+ const reader = stream.getReader();
177
+ const parts = [];
178
+ try {
179
+ for (;;) {
180
+ const chunk = await reader.read();
181
+ if (chunk.done)
182
+ return ok(parts.join(""));
183
+ parts.push(chunk.value);
184
+ }
185
+ }
186
+ catch (error) {
187
+ return err(error instanceof AiError
188
+ ? error.failure
189
+ : { kind: "failed", detail: "the chat stream broke", cause: error });
190
+ }
191
+ };
192
+ class OllamaModel {
193
+ #endpoint;
194
+ #model;
195
+ #contextWindow;
196
+ #system;
197
+ #tools;
198
+ #maxToolRounds;
199
+ #reportOverflow;
200
+ /** The last turn's counts, which is what the context holds now rather than a running sum. */
201
+ #usedTokens = 0;
202
+ constructor(config, options) {
203
+ this.#endpoint = toEndpoint(config);
204
+ this.#model = config.model;
205
+ this.#contextWindow = config.contextWindow ?? DEFAULTS.contextWindow;
206
+ this.#system = options.session.system;
207
+ this.#tools = options.request.tools ?? [];
208
+ this.#maxToolRounds = config.maxToolRounds ?? DEFAULTS.maxToolRounds;
209
+ this.#reportOverflow = options.reportOverflow;
210
+ }
211
+ generateStream = async (input, request) => {
212
+ const conversation = this.#toConversation(input, request);
213
+ const responseResult = await this.#chat(conversation, request, true);
214
+ if (!responseResult.ok)
215
+ return responseResult;
216
+ const body = responseResult.value.body;
217
+ if (body === null)
218
+ return err({ kind: "failed", detail: "the chat sent no body" });
219
+ return ok(this.#streamRounds(body, conversation, request));
220
+ };
221
+ generateWhole = async (input, request) => {
222
+ // A turn with tools is rounds, and rounds are the streaming path read to
223
+ // its end; only a plain turn has a whole-answer call worth a second shape.
224
+ if (this.#tools.length > 0) {
225
+ const streamResult = await this.generateStream(input, request);
226
+ return streamResult.ok ? readWhole(streamResult.value) : streamResult;
227
+ }
228
+ const conversation = this.#toConversation(input, request);
229
+ const responseResult = await this.#chat(conversation, request, false);
230
+ if (!responseResult.ok)
231
+ return responseResult;
232
+ const parsedBody = asRecord(await responseResult.value.json().catch(() => null));
233
+ if (parsedBody === null)
234
+ return err({ kind: "failed", detail: "the chat sent no JSON" });
235
+ const answerText = asString(asRecord(parsedBody.message)?.content);
236
+ if (answerText === null)
237
+ return err({ kind: "failed", detail: "the chat sent no message" });
238
+ this.#charge(parsedBody);
239
+ return ok(answerText);
240
+ };
241
+ usage = () => {
242
+ const used = tokens(this.#usedTokens) ?? ZERO_TOKENS;
243
+ return contextUsage(used, this.#contextWindow);
244
+ };
245
+ /**
246
+ * Nothing to release. The daemon unloads on its own timer, and telling it to
247
+ * unload here would take the model out from under whoever else is on it.
248
+ */
249
+ dispose = () => undefined;
250
+ #toConversation(input, request) {
251
+ const askedMessage = { role: "user", content: input };
252
+ const conversation = [...request.history, askedMessage];
253
+ return this.#system === undefined
254
+ ? conversation
255
+ : [{ role: "user", content: this.#system }, ...conversation];
256
+ }
257
+ async #chat(messages, request, stream) {
258
+ const body = {
259
+ model: this.#model,
260
+ messages,
261
+ stream,
262
+ options: { num_ctx: this.#contextWindow },
263
+ ...(request.schema === undefined ? {} : { format: request.schema }),
264
+ ...(this.#tools.length === 0
265
+ ? {}
266
+ : { tools: toOllamaTools(this.#tools) }),
267
+ };
268
+ const response = await postTo(this.#endpoint, "/api/chat", body, request.signal);
269
+ return response.ok
270
+ ? ok(response)
271
+ : err(await failureFromResponse(response));
272
+ }
273
+ /**
274
+ * Rounds: an answer is read to its end, and where it ends in tool calls the
275
+ * calls are answered and the conversation sent again. Only text reaches the
276
+ * caller, so a turn that is all calls is silent until its last round.
277
+ * Bounded, because a model that keeps calling would spend the window on it:
278
+ * `granite4:350m` asks for the same listing until something stops it.
279
+ */
280
+ #streamRounds(firstBody, firstConversation, request) {
281
+ let conversation = firstConversation;
282
+ let currentRound = this.#openRound(firstBody);
283
+ let roundsTaken = 0;
284
+ const advance = async (controller) => {
285
+ for (;;) {
286
+ const chunk = await currentRound.reader.read();
287
+ if (!chunk.done) {
288
+ controller.enqueue(chunk.value);
289
+ return;
290
+ }
291
+ if (currentRound.toolCalls.length === 0) {
292
+ controller.close();
293
+ return;
294
+ }
295
+ roundsTaken += 1;
296
+ if (roundsTaken > this.#maxToolRounds) {
297
+ throw new AiError({
298
+ kind: "failed",
299
+ detail: `the model called tools ${roundsTaken} times without answering`,
300
+ });
301
+ }
302
+ conversation = await this.#answerToolCalls(conversation, currentRound, request.signal);
303
+ const nextResult = await this.#chat(conversation, request, true);
304
+ if (!nextResult.ok)
305
+ throw new AiError(nextResult.error);
306
+ const nextBody = nextResult.value.body;
307
+ if (nextBody === null)
308
+ throw new AiError({
309
+ kind: "failed",
310
+ detail: "the chat sent no body",
311
+ });
312
+ currentRound = this.#openRound(nextBody);
313
+ }
314
+ };
315
+ return new ReadableStream({
316
+ pull: (controller) => advance(controller),
317
+ cancel: (reason) => currentRound.reader.cancel(reason),
318
+ });
319
+ }
320
+ /** Content out, calls and counts kept: the last line carries both `done` and the totals. */
321
+ #openRound(body) {
322
+ const contentParts = [];
323
+ const toolCalls = [];
324
+ const readLines = new TransformStream({
325
+ transform: (line, controller) => {
326
+ const parsedLine = asRecord(parseJson(line));
327
+ if (parsedLine === null)
328
+ return;
329
+ const errorText = asString(parsedLine.error);
330
+ if (errorText !== null)
331
+ throw new AiError({ kind: "failed", detail: errorText });
332
+ if (parsedLine.done === true)
333
+ this.#charge(parsedLine);
334
+ const message = asRecord(parsedLine.message);
335
+ toolCalls.push(...readToolCalls(message));
336
+ const delta = asString(message?.content) ?? "";
337
+ if (delta === "")
338
+ return;
339
+ contentParts.push(delta);
340
+ controller.enqueue(delta);
341
+ },
342
+ });
343
+ const deltas = body
344
+ .pipeThrough(new TextDecoderStream())
345
+ .pipeThrough(ndjsonLines())
346
+ .pipeThrough(readLines);
347
+ return { reader: deltas.getReader(), contentParts, toolCalls };
348
+ }
349
+ /**
350
+ * The model's call goes back as its own turn, then each answer with the
351
+ * `tool` role, which is what the daemon's template expects. A name the model
352
+ * made up is answered by name rather than failing the turn: the model can
353
+ * pick again, where a failed turn could not. A tool that throws cannot be
354
+ * answered for, and ends the turn.
355
+ */
356
+ async #answerToolCalls(conversation, round, signal) {
357
+ const answered = [
358
+ ...conversation,
359
+ {
360
+ role: "assistant",
361
+ content: round.contentParts.join(""),
362
+ tool_calls: round.toolCalls,
363
+ },
364
+ ];
365
+ for (const call of round.toolCalls) {
366
+ const name = call.function.name;
367
+ const tool = findTool(this.#tools, name);
368
+ if (tool === undefined) {
369
+ answered.push({
370
+ role: "tool",
371
+ content: `there is no tool called "${name}"`,
372
+ tool_name: name,
373
+ });
374
+ continue;
375
+ }
376
+ const toolResult = await runTool(tool, call.function.arguments, signal);
377
+ if (!toolResult.ok)
378
+ throw new AiError(toolResult.error);
379
+ answered.push({
380
+ role: "tool",
381
+ content: toolResult.value,
382
+ tool_name: name,
383
+ });
384
+ }
385
+ return answered;
386
+ }
387
+ /**
388
+ * `prompt_eval_count` is the whole prompt, history included, so the two
389
+ * counts together are what the window holds after this turn. Summing across
390
+ * turns would count the history once per turn.
391
+ */
392
+ #charge(finishedLine) {
393
+ const promptTokens = asNumber(finishedLine.prompt_eval_count) ?? 0;
394
+ const answerTokens = asNumber(finishedLine.eval_count) ?? 0;
395
+ this.#usedTokens = promptTokens + answerTokens;
396
+ if (this.#hasSpentTheWindow(finishedLine))
397
+ this.#reportOverflow();
398
+ }
399
+ /**
400
+ * A daemon that shifts context answers past `num_ctx` and the counts say so
401
+ * on their own. One that stops instead ends the turn with `done_reason:
402
+ * "length"` on counts that reach the window and go no further — the same
403
+ * overflow, told rather than counted. Both are read here because which one
404
+ * the daemon does is its build's to decide, not the caller's.
405
+ *
406
+ * `num_predict` is left unset, so `length` can only be the window; the count
407
+ * is checked anyway, in case a model file sets one.
408
+ */
409
+ #hasSpentTheWindow(finishedLine) {
410
+ if (this.#usedTokens < this.#contextWindow)
411
+ return false;
412
+ return asString(finishedLine.done_reason) === "length";
413
+ }
414
+ }
415
+ const ZERO_TOKENS = tokens(0);
416
+ const connectOllama = async (config, options) => {
417
+ const endpoint = toEndpoint(config);
418
+ let downloadedModels;
419
+ try {
420
+ downloadedModels = await listModels(endpoint);
421
+ }
422
+ catch (cause) {
423
+ return err({ kind: "unsupported", cause });
424
+ }
425
+ if (!downloadedModels.includes(config.model)) {
426
+ const pullResult = await pullModel(endpoint, config.model, options.reportProgress);
427
+ if (!pullResult.ok)
428
+ return pullResult;
429
+ }
430
+ return ok(new OllamaModel(config, options));
431
+ };
432
+ export function makeOllamaProvider(config) {
433
+ const backend = {
434
+ name: "ollama",
435
+ modalities: ["text"],
436
+ tools: true,
437
+ availability: () => getAvailability(config),
438
+ connect: (options) => connectOllama(config, options),
439
+ };
440
+ return createProvider(backend);
441
+ }
442
+ //# sourceMappingURL=ollama.js.map