modelpact-providers 1.0.0 → 1.2.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,20 @@
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, any OpenAI-compatible server, the model inside
10
+ Chrome, and a model in the tab itself. Four backends, one contract, and that
11
+ contract's suite green on each.**
12
+
13
+ The engine holds the contract, the lifecycle and one backend with nothing
14
+ behind it. This package holds the ones with something behind them. They are
15
+ apart because they change for different reasons: a daemon's JSON, a hosted
16
+ API's dialect, a browser's origin trial and a WebGPU runtime each move on their
17
+ own clock, and none of them should move the contract.
4
18
 
5
19
  ## Install
6
20
 
@@ -8,19 +22,258 @@ Language-model backends written against the modelpact contract — one transport
8
22
  npm install modelpact-providers modelpact
9
23
  ```
10
24
 
11
- `modelpact` is a peer dependency: this package is built against its contract
12
- and does not carry a copy of it.
25
+ `modelpact` is a peer dependency: this package is written against its contract
26
+ and carries no copy of it.
27
+
28
+ ## The four
29
+
30
+ | Provider | Reaches | Wants | Import from |
31
+ | ----------------------- | ----------------------------------------- | -------------------------------------------------- | ---------------------------- |
32
+ | `makeOllamaProvider` | a daemon over HTTP, usually local | Ollama on `127.0.0.1:11434` | `modelpact-providers` |
33
+ | `makeOpenAiProvider` | anything speaking the OpenAI HTTP dialect | a `baseUrl`; a key only where the server wants one | `modelpact-providers` |
34
+ | `makePromptApiProvider` | Chrome's built-in Gemini Nano | Chrome, and the weights downloaded once | `modelpact-providers` |
35
+ | `makeWebGpuProvider` | a model in the tab, on WebGPU | `@mlc-ai/web-llm`, and a GPU | `modelpact-providers/webgpu` |
36
+
37
+ ```ts
38
+ import { makeOllamaProvider } from "modelpact-providers";
39
+
40
+ const access = await makeOllamaProvider({ model: "granite4:350m" }).access();
41
+ if (access.kind !== "ready") return;
42
+ const opened = await access.open({ system: "Answer in one sentence." });
43
+ ```
44
+
45
+ Everything after the provider line is the contract's, and identical across the
46
+ four — see [modelpact's README](https://github.com/AvdienkoSergey/modelpact#readme)
47
+ for what a session promises.
48
+
49
+ ## Two entries, and the reason
50
+
51
+ `modelpact-providers` is the three that cost a consumer nothing: `fetch` and
52
+ JSON for the daemon and for the OpenAI dialect, a global for the browser's own
53
+ model, no runtime dependency behind any of them.
54
+ `modelpact-providers/webgpu` is the fourth, because it carries
55
+ `@mlc-ai/web-llm` — an optional peer dependency, so an app on the daemon never
56
+ installs it. The split is by dependency, not by kind: the same reason
57
+ `modelpact/testing` is a separate entry for `vitest`.
58
+
59
+ ## What each one is
60
+
61
+ **Ollama.** Three endpoints are the whole backend — `/api/tags` says what is
62
+ downloaded, `/api/pull` downloads, `/api/chat` generates. Shapes were read off
63
+ a running daemon, not off the docs: a chat stream is NDJSON whose last line
64
+ carries the counts, a pull line carries `completed` and `total` per layer, and
65
+ an error is an HTTP status with a body. The daemon keeps nothing between
66
+ requests, so the session's record is resent whole every turn.
67
+
68
+ **An OpenAI-compatible server.** A dialect rather than a company:
69
+ `https://api.openai.com/v1` is the default and one instance of it, and the same
70
+ two endpoints answer on vLLM, llama.cpp, LM Studio, OpenRouter, Groq and
71
+ Ollama's own `/v1`. So `baseUrl` is the whole difference between them and
72
+ `apiKey` is optional — a model on your machine wants no key, and a type that
73
+ demanded one would be describing a service instead of a protocol. It is also
74
+ the only backend here that never answers `needs-download`: the weights are the
75
+ server's problem, and there is nothing this side could fetch.
76
+
77
+ Two things the wire does that the daemon next door does not, and both are
78
+ handled rather than assumed: a tool call arrives fragmented across frames,
79
+ keyed by `index`, with its arguments as a JSON string rather than an object;
80
+ and the counts arrive in a frame of their own after the last one carrying text,
81
+ which is what `stream_options: { include_usage: true }` asks for.
82
+
83
+ `contextWindow` is optional, and absent it `usage()` answers `unknown`. No
84
+ server in this dialect reports the window it loaded a model with, so a number
85
+ there is your declaration, not a discovery — and being a declaration is what
86
+ makes it a budget too: a transcript past it is an overflow, told by the counts
87
+ rather than by the server.
88
+
89
+ **Chrome's built-in model.** The one backend that keeps the conversation
90
+ itself: `LanguageModel` is a session object and `prompt()` appends to it, so
91
+ the record travels with the session rather than in the request. It fires its
92
+ own `contextoverflow`, which is forwarded rather than re-derived, and reports
93
+ usage against a window it decides — 9 216 tokens on Chrome 152, measured.
94
+ The declarations are `@types/dom-chromium-ai`, patched under
95
+ [`patches/`](patches) because the IDL is looser than the spec: several states
96
+ the algorithm rejects at runtime are writable in the types, and a TS error at
97
+ the keyboard beats a `TypeError` in the browser.
98
+
99
+ **WebGPU.** A model in the tab through `@mlc-ai/web-llm`, and the only backend
100
+ whose download costs the user their bandwidth rather than a daemon's. It was
101
+ written before this package existed, in a directory one repository over, to
102
+ answer one question: is the published API enough to write a backend with. It
103
+ was, and it needed nothing added to the contract.
104
+
105
+ ## Tools
106
+
107
+ All four accept `ModelRequest.tools`, and each executes them the way its
108
+ transport can.
109
+
110
+ | Provider | How a call happens |
111
+ | ---------- | --------------------------------------------------------------------------------------------------- |
112
+ | Ollama | native `tool_calls`, answered under the `tool` role, in rounds bounded by `maxToolRounds` |
113
+ | OpenAI | the same, tied by `tool_call_id` rather than by name, so two calls to one tool in a turn stay apart |
114
+ | Prompt API | handed to `create()`, and the browser calls `execute` itself |
115
+ | WebGPU | not yet: the request is refused at `access`, which is the contract's answer for a backend without |
116
+
117
+ Chrome 152 answers `available` to `availability()` with tools and then throws
118
+ `InvalidStateError` from `create()` — measured, and it arrives as a refusal at
119
+ `open`. A loop above should expect a refusal in both places and fall back to a
120
+ schema-constrained answer.
121
+
122
+ ## The guard
123
+
124
+ [`src/surface.ts`](src/surface.ts) names every published type on both sides —
125
+ the engine's and this package's — and
126
+ [`tsconfig.surface.json`](tsconfig.surface.json) compiles it with
127
+ `skipLibCheck` off and `types: []`, which is how a consumer reads a `.d.ts`. A
128
+ declaration that needs an ambient global fails there and nowhere else.
129
+
130
+ That guard has found the same bug three times, in three packages. A published
131
+ type naming `LanguageModel` broke a consumer who never installed
132
+ `@types/dom-chromium-ai`. A `WebGpuConfig.engine` typed with
133
+ `MLCEngineInterface` broke one the same way, through `@mlc-ai/web-llm`'s own
134
+ declarations, which name packages they do not depend on. Both fixes are the
135
+ same: a structural type of exactly what the backend uses, named locally, and no
136
+ third-party type in any exported signature.
137
+
138
+ Moving here found a third. `@mlc-ai/web-llm`'s `interruptGenerate()` returns a
139
+ promise, though its own published interface says it returns nothing; the
140
+ adapter called it and dropped that promise on the floor. It is marked `void`
141
+ now, deliberately, because by the time it runs the lifecycle has already
142
+ answered the caller.
143
+
144
+ Three tsconfigs, and each has one job:
145
+
146
+ | File | Checks |
147
+ | ------------------------------------------------ | -------------------------------------------------------------------------- |
148
+ | [`tsconfig.json`](tsconfig.json) | the source. `skipLibCheck` on, because `@mlc-ai/web-llm` cannot survive it |
149
+ | [`tsconfig.patched.json`](tsconfig.patched.json) | everything but WebGPU, with `skipLibCheck` off — the patch, still applying |
150
+ | [`tsconfig.surface.json`](tsconfig.surface.json) | the emitted declarations, as a consumer receives them |
151
+
152
+ [`demo/tsconfig.json`](demo/tsconfig.json) is the fourth, and the only one that
153
+ is an application rather than a check: `types: ["vite/client"]`, no ambient
154
+ Prompt API and no `@mlc-ai/web-llm`, which is the same claim made by code that
155
+ actually imports and calls the things. It is where the demo caught the
156
+ `MLCEngineInterface` leak the first time.
13
157
 
14
158
  ## Scripts
15
159
 
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 |
160
+ | Script | What it does |
161
+ | ----------------------- | ------------------------------------------------------ |
162
+ | `npm run typecheck` | both source configs, and the specs |
163
+ | `npm run lint` | ESLint, type-aware |
164
+ | `npm run format:check` | Prettier, check only |
165
+ | `npm test` | Vitest; the Ollama contract suite skips with no daemon |
166
+ | `npm run test:e2e` | Playwright; starts the demo itself |
167
+ | `npm run check:surface` | builds, then reads the declarations from outside |
168
+ | `npm run demo` | the demo on a dev server |
169
+ | `npm run demo:check` | the demo's own tsconfig — a consumer with no `@types` |
170
+ | `npm run build` | `dist/` — JS, declarations, maps |
171
+
172
+ The Ollama suite wants a daemon on `127.0.0.1:11434` holding `granite4:350m`,
173
+ and the OpenAI suite wants an OpenAI-compatible server — that same daemon's
174
+ `/v1`, by default, which is a genuine third-party implementation of the dialect
175
+ and already there. Without one, each skips loudly rather than passing quietly.
176
+
177
+ ## The demo
178
+
179
+ [`demo/`](demo) is a chat with all four transports behind one picker, and
180
+ nothing else behind it: no mock, because the engine's demo has one and this
181
+ repository is about what happens when there is something on the other end. On a
182
+ machine with no daemon, no Gemini Nano and no GPU, every entry therefore answers
183
+ `unavailable` — honest, and dull. A daemon on `127.0.0.1:11434` holding
184
+ `granite4:350m` is what makes two of the four entries answer.
185
+
186
+ ```sh
187
+ npm install
188
+ npm run demo
189
+ ```
190
+
191
+ It takes `modelpact` from npm, at the version a stranger would get, and this
192
+ package from `file:..` through its own `exports` — so what runs in the page is
193
+ `dist/`, never `src/`. Both land on one copy of the engine because `demo` is a
194
+ workspace of this repository; the reason that matters, and what breaks without
195
+ it, is in [`demo/README.md`](demo/README.md).
196
+
197
+ Export `OPENAI_API_KEY` before `npm run demo` and the `openai` entry reaches
198
+ `api.openai.com` instead of the daemon, through a dev-server proxy that holds
199
+ the key in Node — the page is never told it, and a build never carries it. See
200
+ [`demo/README.md`](demo/README.md#pointing-it-at-hosted-openai).
201
+
202
+ It also type-checks itself with `types: ["vite/client"]` and nothing else,
203
+ which makes it the second half of the surface guard above: a declaration that
204
+ needs an ambient global fails in an app that never installed one.
205
+
206
+ ## The browser suite
207
+
208
+ [`e2e/demo-e2e.spec.ts`](e2e/demo-e2e.spec.ts) drives the demo in Chromium.
209
+
210
+ ```sh
211
+ npx playwright install chromium # once
212
+ npm run test:e2e # the demo server starts itself
213
+ ```
214
+
215
+ Eleven specs. The vitest suites next to each backend check its logic against
216
+ the contract from node; these check what node cannot see:
217
+
218
+ - a page is not node. `fetch` held on its own throws `Illegal invocation` in
219
+ one and works in the other, and that bug is invisible to any vitest run;
220
+ - `LanguageModel` and `navigator.gpu` exist in a browser and nowhere else, so
221
+ the Prompt API and WebGPU backends have their first real `availability()`
222
+ call here;
223
+ - the emitted `.d.ts` files, a bundler, and React sit between the app and the
224
+ backend, which no unit test reproduces.
225
+
226
+ Seven of them want a daemon and skip without one — everything that needs a
227
+ session to actually open, which since the mock left the picker means every
228
+ promise the contract makes about a session. That is the cost of a demo about
229
+ transports, and it is paid where it belongs: CI installs a daemon for this job.
230
+
231
+ The other four assert a branch rather than a machine, and are green anywhere. A
232
+ runner with no Gemini Nano and no GPU still has `unavailable` to land on, and
233
+ landing on it is the claim. Both HTTP transports get their refusal answer on
234
+ every machine too, including a laptop with Ollama running, because that spec
235
+ refuses the connection itself rather than waiting for a machine without one.
236
+
237
+ ## CI
238
+
239
+ [`.github/workflows/ci.yml`](.github/workflows/ci.yml) has three jobs, and each
240
+ can go red on its own:
241
+
242
+ | Job | Runs |
243
+ | -------- | ------------------------------------------------------------------------ |
244
+ | `check` | typecheck, lint, format, vitest, `check:surface` — one install, no model |
245
+ | `ollama` | the same vitest suites with a daemon answering |
246
+ | `e2e` | Playwright against the demo, with a daemon, in Chromium |
247
+
248
+ **Adding the browser suite to a fork or another pipeline** is four steps:
249
+
250
+ ```yaml
251
+ - uses: actions/setup-node@v7
252
+ with:
253
+ node-version-file: .nvmrc
254
+ cache: npm
255
+ # `demo` is a workspace, so this installs it too — and puts one copy of
256
+ # `modelpact` where both it and this package find it.
257
+ - run: npm ci
258
+ - run: npx playwright install --with-deps chromium
259
+ - run: npm run test:e2e
260
+ ```
261
+
262
+ `npm run test:e2e` starts the demo itself, and the demo's `predev` builds
263
+ `dist/` first, so there is no build step to add and no server to start.
264
+
265
+ Two options on top of that:
266
+
267
+ - **A daemon**, which is not really optional any more: half the specs skip
268
+ without one. Install Ollama, `ollama serve`, `ollama pull granite4:350m`, and
269
+ cache `~/.ollama/models` — the `e2e` job does exactly this, restore-only,
270
+ sharing the key the `ollama` job saves. Without it the suite still passes,
271
+ on the four specs that assert a branch rather than a session.
272
+ - **The report on failure**, which is `actions/upload-artifact` over
273
+ `playwright-report/`. Playwright writes it whether or not anyone collects it.
274
+
275
+ Nothing here needs a GPU or a Chrome with Gemini Nano. Those two backends
276
+ answer `unavailable` on a plain runner, and the specs assert the branch.
24
277
 
25
278
  ## Releases
26
279
 
package/dist/index.d.ts CHANGED
@@ -1,2 +1,14 @@
1
- export {};
1
+ /**
2
+ * The transports that cost a consumer nothing but this package: `fetch` and
3
+ * JSON for the daemon and for the OpenAI dialect, a global for the browser's
4
+ * own model, no runtime dependency behind any of them. Import one and a
5
+ * bundler drops the rest.
6
+ *
7
+ * The WebGPU backend is `modelpact-providers/webgpu` instead, because it
8
+ * carries `@mlc-ai/web-llm` — a separate entry for a separate dependency, the
9
+ * way `modelpact/testing` is separate for `vitest`.
10
+ */
11
+ export { makeOllamaProvider, type OllamaConfig } from "./ollama.js";
12
+ export { makeOpenAiProvider, type OpenAiConfig } from "./openai.js";
13
+ export { makePromptApiProvider } from "./prompt-api.js";
2
14
  //# 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;;;;;;;;;GASG;AAEH,OAAO,EAAE,kBAAkB,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AACpE,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,14 @@
1
- export {};
1
+ /**
2
+ * The transports that cost a consumer nothing but this package: `fetch` and
3
+ * JSON for the daemon and for the OpenAI dialect, a global for the browser's
4
+ * own model, no runtime dependency behind any of them. Import one and a
5
+ * bundler drops the rest.
6
+ *
7
+ * The WebGPU backend is `modelpact-providers/webgpu` instead, because it
8
+ * carries `@mlc-ai/web-llm` — a separate entry for a separate dependency, the
9
+ * way `modelpact/testing` is separate for `vitest`.
10
+ */
11
+ export { makeOllamaProvider } from "./ollama.js";
12
+ export { makeOpenAiProvider } from "./openai.js";
13
+ export { makePromptApiProvider } from "./prompt-api.js";
2
14
  //# 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;;;;;;;;;GASG;AAEH,OAAO,EAAE,kBAAkB,EAAqB,MAAM,aAAa,CAAC;AACpE,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"}