modelpact-providers 0.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 +127 -11
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/ollama.d.ts +39 -0
- package/dist/ollama.d.ts.map +1 -0
- package/dist/ollama.js +442 -0
- package/dist/ollama.js.map +1 -0
- package/dist/prompt-api.d.ts +26 -0
- package/dist/prompt-api.d.ts.map +1 -0
- package/dist/prompt-api.js +261 -0
- package/dist/prompt-api.js.map +1 -0
- package/dist/webgpu.d.ts +73 -0
- package/dist/webgpu.d.ts.map +1 -0
- package/dist/webgpu.js +214 -0
- package/dist/webgpu.js.map +1 -0
- package/package.json +26 -5
package/README.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# modelpact-providers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/modelpact-providers)
|
|
4
|
+
[](https://github.com/AvdienkoSergey/modelpact-providers/actions/workflows/ci.yml)
|
|
5
|
+

|
|
6
|
+
[](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 @@ Backends for modelpact
|
|
|
8
21
|
npm install modelpact-providers modelpact
|
|
9
22
|
```
|
|
10
23
|
|
|
11
|
-
`modelpact` is a peer dependency: this package is
|
|
12
|
-
and
|
|
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
|
|
17
|
-
|
|
|
18
|
-
| `npm run typecheck`
|
|
19
|
-
| `npm run lint`
|
|
20
|
-
| `npm run format:check`
|
|
21
|
-
| `npm test`
|
|
22
|
-
| `npm run
|
|
23
|
-
| `npm run build`
|
|
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
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
-
|
|
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"}
|
package/dist/ollama.d.ts
ADDED
|
@@ -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
|