modelpact 2.0.2

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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +372 -0
  3. package/dist/helpers/abort.d.ts +14 -0
  4. package/dist/helpers/abort.d.ts.map +1 -0
  5. package/dist/helpers/abort.js +22 -0
  6. package/dist/helpers/abort.js.map +1 -0
  7. package/dist/helpers/lifetime.d.ts +34 -0
  8. package/dist/helpers/lifetime.d.ts.map +1 -0
  9. package/dist/helpers/lifetime.js +71 -0
  10. package/dist/helpers/lifetime.js.map +1 -0
  11. package/dist/helpers/monitor.d.ts +52 -0
  12. package/dist/helpers/monitor.d.ts.map +1 -0
  13. package/dist/helpers/monitor.js +69 -0
  14. package/dist/helpers/monitor.js.map +1 -0
  15. package/dist/helpers/ndjson.d.ts +19 -0
  16. package/dist/helpers/ndjson.d.ts.map +1 -0
  17. package/dist/helpers/ndjson.js +39 -0
  18. package/dist/helpers/ndjson.js.map +1 -0
  19. package/dist/helpers/overflow.d.ts +50 -0
  20. package/dist/helpers/overflow.d.ts.map +1 -0
  21. package/dist/helpers/overflow.js +72 -0
  22. package/dist/helpers/overflow.js.map +1 -0
  23. package/dist/helpers/transcript.d.ts +22 -0
  24. package/dist/helpers/transcript.d.ts.map +1 -0
  25. package/dist/helpers/transcript.js +26 -0
  26. package/dist/helpers/transcript.js.map +1 -0
  27. package/dist/index.d.ts +23 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +19 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/lifecycle/01_access.d.ts +13 -0
  32. package/dist/lifecycle/01_access.d.ts.map +1 -0
  33. package/dist/lifecycle/01_access.js +45 -0
  34. package/dist/lifecycle/01_access.js.map +1 -0
  35. package/dist/lifecycle/02_download.d.ts +8 -0
  36. package/dist/lifecycle/02_download.d.ts.map +1 -0
  37. package/dist/lifecycle/02_download.js +12 -0
  38. package/dist/lifecycle/02_download.js.map +1 -0
  39. package/dist/lifecycle/03_open.d.ts +22 -0
  40. package/dist/lifecycle/03_open.d.ts.map +1 -0
  41. package/dist/lifecycle/03_open.js +39 -0
  42. package/dist/lifecycle/03_open.js.map +1 -0
  43. package/dist/lifecycle/04_generate.d.ts +15 -0
  44. package/dist/lifecycle/04_generate.d.ts.map +1 -0
  45. package/dist/lifecycle/04_generate.js +154 -0
  46. package/dist/lifecycle/04_generate.js.map +1 -0
  47. package/dist/lifecycle/05_close.d.ts +4 -0
  48. package/dist/lifecycle/05_close.d.ts.map +1 -0
  49. package/dist/lifecycle/05_close.js +8 -0
  50. package/dist/lifecycle/05_close.js.map +1 -0
  51. package/dist/providers/create.d.ts +5 -0
  52. package/dist/providers/create.d.ts.map +1 -0
  53. package/dist/providers/create.js +7 -0
  54. package/dist/providers/create.js.map +1 -0
  55. package/dist/providers/mock.d.ts +36 -0
  56. package/dist/providers/mock.d.ts.map +1 -0
  57. package/dist/providers/mock.js +128 -0
  58. package/dist/providers/mock.js.map +1 -0
  59. package/dist/providers/ollama.d.ts +33 -0
  60. package/dist/providers/ollama.d.ts.map +1 -0
  61. package/dist/providers/ollama.js +275 -0
  62. package/dist/providers/ollama.js.map +1 -0
  63. package/dist/providers/prompt-api.d.ts +23 -0
  64. package/dist/providers/prompt-api.d.ts.map +1 -0
  65. package/dist/providers/prompt-api.js +203 -0
  66. package/dist/providers/prompt-api.js.map +1 -0
  67. package/dist/providers/registry.d.ts +22 -0
  68. package/dist/providers/registry.d.ts.map +1 -0
  69. package/dist/providers/registry.js +30 -0
  70. package/dist/providers/registry.js.map +1 -0
  71. package/dist/testing/contract.d.ts +47 -0
  72. package/dist/testing/contract.d.ts.map +1 -0
  73. package/dist/testing/contract.js +518 -0
  74. package/dist/testing/contract.js.map +1 -0
  75. package/dist/testing/index.d.ts +9 -0
  76. package/dist/testing/index.d.ts.map +1 -0
  77. package/dist/testing/index.js +9 -0
  78. package/dist/testing/index.js.map +1 -0
  79. package/dist/types/backend.d.ts +63 -0
  80. package/dist/types/backend.d.ts.map +1 -0
  81. package/dist/types/backend.js +11 -0
  82. package/dist/types/backend.js.map +1 -0
  83. package/dist/types/failures.d.ts +115 -0
  84. package/dist/types/failures.d.ts.map +1 -0
  85. package/dist/types/failures.js +68 -0
  86. package/dist/types/failures.js.map +1 -0
  87. package/dist/types/foundations.d.ts +35 -0
  88. package/dist/types/foundations.d.ts.map +1 -0
  89. package/dist/types/foundations.js +30 -0
  90. package/dist/types/foundations.js.map +1 -0
  91. package/dist/types/messages.d.ts +30 -0
  92. package/dist/types/messages.d.ts.map +1 -0
  93. package/dist/types/messages.js +2 -0
  94. package/dist/types/messages.js.map +1 -0
  95. package/dist/types/provider.d.ts +22 -0
  96. package/dist/types/provider.d.ts.map +1 -0
  97. package/dist/types/provider.js +2 -0
  98. package/dist/types/provider.js.map +1 -0
  99. package/dist/types/session.d.ts +156 -0
  100. package/dist/types/session.d.ts.map +1 -0
  101. package/dist/types/session.js +2 -0
  102. package/dist/types/session.js.map +1 -0
  103. package/dist/types/usage.d.ts +25 -0
  104. package/dist/types/usage.d.ts.map +1 -0
  105. package/dist/types/usage.js +16 -0
  106. package/dist/types/usage.js.map +1 -0
  107. package/package.json +85 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergey Avdienko
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,372 @@
1
+ # modelpact
2
+
3
+ [![npm](https://img.shields.io/npm/v/modelpact)](https://www.npmjs.com/package/modelpact)
4
+ [![ci](https://github.com/AvdienkoSergey/modelpact/actions/workflows/ci.yml/badge.svg?event=pull_request)](https://github.com/AvdienkoSergey/modelpact/actions/workflows/ci.yml)
5
+ ![dependencies: 0](https://img.shields.io/badge/dependencies-0-brightgreen)
6
+ ![min+gzip: 4.9 kB](https://img.shields.io/badge/min%2Bgzip-4.9%20kB-blue)
7
+ ![types: TypeScript](https://img.shields.io/badge/types-TypeScript-3178c6)
8
+ ![node: ≥22](https://img.shields.io/badge/node-%E2%89%A522-339933)
9
+ [![license: MIT](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE)
10
+
11
+ **One way to talk to a local language model — the one built into the browser,
12
+ Ollama on your machine, or a mock in your tests. Swap the backend, keep the
13
+ code.**
14
+
15
+ ## Why you'd want it
16
+
17
+ Chrome now ships a language model inside the browser. It runs on the user's
18
+ device: free, offline, private, no API key. For a web app that is the best deal
19
+ in AI — as long as your users are on Chrome.
20
+
21
+ Everyone else needs a fallback, and every fallback speaks its own dialect. A
22
+ different way to ask "are you available?", a different download story, different
23
+ errors, a different streaming format. Support two backends and you maintain two
24
+ integrations that share nothing.
25
+
26
+ modelpact is one dialect for all of them.
27
+
28
+ ## What you get
29
+
30
+ **Swap backends with one line.** The provider is the only place a backend is
31
+ named. Everything after it is identical.
32
+
33
+ ```ts
34
+ const provider = makePromptApiProvider(); // Chrome's built-in model
35
+ const provider = makeOllamaProvider({ model: "granite4:350m" }); // …or a daemon
36
+ const provider = makeMockProvider(); // …or nothing at all, in tests
37
+ ```
38
+
39
+ **Failures that say what to do next.** No parsing exception names. Each failure
40
+ is a plain word with the data you need to act on it: `context-overflow` comes
41
+ with the usage so you can trim, `busy` tells you which call holds the session,
42
+ `unsupported-input` says what was wrong with your message.
43
+
44
+ **Bugs that don't compile.** You cannot open a session on a model that isn't
45
+ there — the method doesn't exist on that branch. You cannot read an answer
46
+ without handling the failure — the field isn't in the type. TypeScript catches
47
+ it before your users do.
48
+
49
+ **A conversation you can carry across a reload.** The session keeps the record:
50
+ what you handed it, then every completed turn. It lives in memory for one tab,
51
+ so storing it is yours — `localStorage`, IndexedDB, your server. Read it, store
52
+ it, hand it back to `open`, and the conversation continues where it was. An
53
+ aborted turn never gets in, so what you store is what the model actually saw.
54
+
55
+ **Download progress for free.** The first time a browser model is used, hundreds
56
+ of megabytes move. You get a progress event; your users get a bar instead of a
57
+ frozen page.
58
+
59
+ **Tests that need no GPU.** The mock provider is a first-class backend. Your
60
+ suite runs on any CI box.
61
+
62
+ **Bring your own backend and prove it.** Wrote an adapter for something else?
63
+ Run it through the same conformance suite the built-in providers pass. If it's
64
+ green, it behaves like the others — not "probably", provably.
65
+
66
+ ## See it before you install it
67
+
68
+ [`demo/`](demo/) is one chat screen with a picker at the top. The picker holds
69
+ six backends, and each of them was one line in
70
+ [`demo/src/providers.ts`](demo/src/providers.ts) — nothing else on the screen
71
+ knows which one is answering.
72
+
73
+ ```sh
74
+ cd demo && npm install && npm run dev
75
+ ```
76
+
77
+ | Pick | What answers |
78
+ | -------------------------------------- | -------------------------------------------------------------------- |
79
+ | `mock`, `mock-narrow`, `mock-download` | nothing: canned words, streamed one at a time, to stage every branch |
80
+ | `ollama` | `granite4:350m` — 708 MB on your machine, offline once it is pulled |
81
+ | `prompt-api` | Chrome's built-in model, behind a consent button for its download |
82
+ | `webgpu` | `SmolLM2-360M` in the tab itself, from a package outside this repo |
83
+
84
+ <p align="center">
85
+ <img src="docs/screenshots/demo-overflow.png" width="49%" alt="The mock on a 60-token window: three turns in, the meter reads 140 / 60 and the overflow notice has fired once.">
86
+ <img src="docs/screenshots/demo-chrome.png" width="49%" alt="Chrome's built-in model with no weights on this machine yet: the chip says fetching weights, and the download waits for the button.">
87
+ </p>
88
+
89
+ Everything on that screen is one of the promises above: words arriving one at a
90
+ time, a **Stop** that leaves the session open, the interrupted answer gone from
91
+ the record, a meter, a chip per `AccessKind`, an overflow warning that fires
92
+ once, a download bar that waits to be asked, and a conversation that survives a
93
+ reload and stays in step across two tabs. Nine Playwright specs — one per
94
+ promise, and one per real backend in the picker — drive it in Chromium and are
95
+ the repo's whole browser suite.
96
+
97
+ The last three rows are the point. A real model on a daemon, the browser's own
98
+ model, and a model on WebGPU are not three integrations here. They are three
99
+ entries in a registry, and the switch over that registry stays exhaustive — add
100
+ a seventh and the build says where a sentence is missing.
101
+
102
+ ## The step, and what stands on it
103
+
104
+ Talking to a model has three storeys. A transport reaches **one model**. A
105
+ session holds **one conversation** with it and carries the guarantees — one
106
+ generation at a time, an abort that leaves the session open, an overflow that
107
+ fires once, a close that refuses everything after it. Above that, several
108
+ models, a policy, a loop over turns: orchestration.
109
+
110
+ modelpact is the middle storey, and it is deliberately only that.
111
+
112
+ | Storey | What lives there | Where |
113
+ | ------------- | ------------------------------------- | ------------------------------------------------------------------------------------ |
114
+ | transport | one model, four answers | `ModelBackend` — three inside the package, two written outside it |
115
+ | session | one conversation, the guarantees | **this package**, and nothing above it |
116
+ | orchestration | several models, a policy, a tool loop | [`external/orchestrator`](external/orchestrator), [`external/agent`](external/agent) |
117
+
118
+ That is the direction this repo is developed in. The package does not grow up
119
+ the stairs; what grows is what stands on it, and [`external/`](external/) is
120
+ where that is tried — each package written the way a stranger would write it,
121
+ against `modelpact` at `file:../..`, through the `exports` map, with no path into
122
+ `src/`. Whatever the published API is not enough for shows up there first.
123
+
124
+ **A transport from outside.** [`external/webgpu-provider`](external/webgpu-provider)
125
+ is a model in the tab on WebGPU, through `@mlc-ai/web-llm`. It answers the same
126
+ four questions, and `describeContract` runs against it: 34 green. It also found
127
+ two real bugs — a published type that named a global consumers do not have, and a
128
+ backend that errored its own stream and landed in `unknown` instead of `aborted`.
129
+ Both fixed; both now have a guard. The demo depends on this package the way it
130
+ would on anything from npm.
131
+
132
+ **One storey up.** [`external/orchestrator`](external/orchestrator) puts Claude
133
+ from `claude -p` and a local Ollama model in one conversation, with a policy
134
+ deciding which side answers. Its first version was a `ModelBackend` composed of
135
+ two — and it passed the suite while every guarantee leaked: a meter over two
136
+ windows, an overflow that meant nothing for the other side. Rebuilt one storey
137
+ up it needed **nothing new from the package**: `open({ history })` was already
138
+ the door for handing a side the turns it missed. That is the test of whether a
139
+ storey is right.
140
+
141
+ **An agent, on the same storey.** [`external/agent`](external/agent) is a
142
+ tool-calling loop over a `Brain` — two methods, `ask` and `record` — which is an
143
+ `AiSession` or an orchestrator behind a two-line adapter. This contract has no
144
+ tool protocol, and it did not need one: a tool call is a schema, honoured or
145
+ refused and never ignored, and where a backend refuses (`claude -p` does,
146
+ measured) the refusal picks prose mode rather than ending the run. A tool result
147
+ goes back as the next user turn. The reference agent's shape survived; its
148
+ `bash` tool and the hundred lines of path containment it needs did not.
149
+
150
+ Three packages, three storeys, and the thing they share is fifteen exports and
151
+ a test suite. Each export is there because something outside needed it; what is
152
+ not there — storage, a router, a tool protocol, a third role — is not missing,
153
+ it lives upstairs. The next transport is an afternoon and a green suite. The
154
+ next policy or loop is a consumer, not a feature.
155
+
156
+ ## Quick start
157
+
158
+ ```sh
159
+ npm install modelpact
160
+ ```
161
+
162
+ ```ts
163
+ import { makeMockProvider } from "modelpact";
164
+
165
+ const access = await makeMockProvider().access();
166
+ if (access.kind !== "ready") return; // unavailable, or needs a download first
167
+
168
+ const opened = await access.open({ system: "Answer in one sentence." });
169
+ if (!opened.ok) return;
170
+
171
+ const reply = await opened.value.prompt("What is this page about?");
172
+ console.log(reply.ok ? reply.value : reply.error.kind);
173
+ ```
174
+
175
+ Swap `makeMockProvider` for any other provider and nothing below it changes.
176
+ That is the whole point of the line.
177
+
178
+ > **What is in.** The contract, its type-level test, the lifecycle, the
179
+ > conformance suite, and three backends — the mock, Ollama, and Chrome's
180
+ > built-in model. Everything a backend needs is exported, and two more backends
181
+ > plus an orchestrator and an agent have been written outside the package on
182
+ > exactly that — see [The step, and what stands on it](#the-step-and-what-stands-on-it).
183
+
184
+ ### Bring your own backend
185
+
186
+ A backend answers four questions. The lifecycle does the rest — one generation
187
+ at a time, an abort that leaves the session open, an overflow that fires once, a
188
+ close that refuses everything after it.
189
+
190
+ ```ts
191
+ import { createProvider, ok, type ModelBackend } from "modelpact";
192
+
193
+ const backend: ModelBackend = {
194
+ name: "echo",
195
+ modalities: ["text"],
196
+ availability: () => ({ kind: "ready" }),
197
+ connect: () => Promise.resolve(ok(model)), // your `generateStream`, `usage`, `dispose`
198
+ };
199
+
200
+ export const echo = createProvider(backend);
201
+ ```
202
+
203
+ Then prove it behaves like the others:
204
+
205
+ ```ts
206
+ import { describeContract } from "modelpact/testing";
207
+
208
+ describeContract("echo", () => echo);
209
+ ```
210
+
211
+ `modelpact/testing` needs `vitest`, which is an optional peer dependency: an app
212
+ that only consumes a provider never loads it.
213
+
214
+ ### Several backends in one app
215
+
216
+ The list of names belongs to the app, not to this package — a backend written
217
+ elsewhere names itself. Your registry is where the set is known, so a switch over
218
+ it stays exhaustive and a string out of storage cannot pretend to be a name.
219
+
220
+ ```ts
221
+ import { defineProviders, findProviderName } from "modelpact";
222
+
223
+ const PROVIDERS = defineProviders({ echo, mock: makeMockProvider() });
224
+
225
+ const name = findProviderName(
226
+ PROVIDERS,
227
+ localStorage.getItem("provider") ?? "",
228
+ );
229
+ const provider = name === null ? null : PROVIDERS[name];
230
+ ```
231
+
232
+ ## Status
233
+
234
+ **Versioned, and released by machine.** Semantic versioning from conventional
235
+ commits: [release-please](https://github.com/googleapis/release-please) opens
236
+ the release PR, [`CHANGELOG.md`](CHANGELOG.md) is written from the history, and
237
+ the tagged tree is what `npm publish` builds, with provenance. A `!` in a commit
238
+ is a major, and 2.0 was one — four public names changed for the better, and the
239
+ changelog says which.
240
+
241
+ **Small on purpose.** ESM only, zero runtime dependencies, and the whole entry
242
+ is 13.6 kB minified, 4.9 kB gzipped, before tree-shaking takes the providers
243
+ you do not import. `modelpact/testing` is a second entry with `vitest` as an
244
+ optional peer, so an app that only consumes a provider never loads it.
245
+
246
+ **Where it runs.** Node ≥ 22 for the Ollama backend and the suites; any current
247
+ browser for a session over `fetch`; Chrome for the built-in model. The
248
+ published declarations are checked by three outside packages with `skipLibCheck`
249
+ off and `types: []` — if a `.d.ts` ever names a global you do not have, that
250
+ build goes red before yours does.
251
+
252
+ **What a pull request has to pass.** Typecheck, lint, format, the vitest suites,
253
+ nine Playwright specs in Chromium, and the three packages under `external/` —
254
+ their own tests and their surface guards — on every change, in one run.
255
+
256
+ **Next.** An OpenAI-compatible HTTP backend: one transport for
257
+ `/v1/chat/completions`, which is the cloud APIs, vLLM, LM Studio and a
258
+ llama.cpp server at once. Same four answers, same suite.
259
+
260
+ ---
261
+
262
+ ## A Deep Dive for Techno-Geeks
263
+
264
+ `@types/dom-chromium-ai` follows the IDL, and the IDL is looser than
265
+ [the spec](docs/mdn/prompt_api/spec.md):
266
+ several states the algorithm rejects at runtime are writable in the types. A TS
267
+ error at the keyboard beats a `TypeError` in the browser, so
268
+ [`patches/@types+dom-chromium-ai+0.0.17.patch`](patches/@types+dom-chromium-ai+0.0.17.patch) closes the gap.
269
+
270
+ The patch is applied by `patch-package` on `prepare`, which runs for this repo
271
+ and never for anyone installing the published package. `skipLibCheck` is
272
+ deliberately **off** in `tsconfig.json`: these declarations are the only
273
+ third-party ones in the project, and type-checking them is how a patch that
274
+ stops applying cleanly gets caught.
275
+
276
+ ## The contract
277
+
278
+ [`src/types`](src/types) is seven files and no runtime dependencies. Each holds one
279
+ layer, and a fact lives on the type it is about: what a field means sits on the
280
+ type rather than at every place it is read, so go-to-definition walks the
281
+ reasoning instead of finding it restated.
282
+
283
+ | File | Holds |
284
+ | -------------------------------------------- | --------------------------------------------------------------------------------- |
285
+ | [`foundations.ts`](src/types/foundations.ts) | `Result`, and the branded `Tokens`, `Fraction`, `JsonSchema`. |
286
+ | [`messages.ts`](src/types/messages.ts) | `AiMessage`, `Modality`, `ModelRequest`. |
287
+ | [`usage.ts`](src/types/usage.ts) | `ContextUsage` — unknown, unbounded or bounded — and `UsageKind`. |
288
+ | [`failures.ts`](src/types/failures.ts) | `AiFailure` and `FailureKind`, the mapping `failureFromError`, `AiError`. |
289
+ | [`session.ts`](src/types/session.ts) | `AiSession`, `ModelAccess` and `AccessKind`, `DownloadMonitor`, the option types. |
290
+ | [`provider.ts`](src/types/provider.ts) | `ProviderName`, `AiProvider`. |
291
+ | [`backend.ts`](src/types/backend.ts) | `ModelBackend`, `ModelConnection` — the four answers a provider supplies. |
292
+
293
+ Four ideas carry the rest:
294
+
295
+ **Refusals are values.** `Result<T, AiFailure>` on the adapter boundary, so the
296
+ failure path is in the signature and cannot be skipped in silence. Inside a
297
+ provider's own implementation, exceptions are fine: `Result` spreads into every
298
+ signature it touches, and only the boundary is worth that cost.
299
+
300
+ **The failure vocabulary is cut by the caller's next move.** Not by exception
301
+ name. Kinds merge where the reaction would be the same and split where it
302
+ differs, even when the spec throws one exception for both — `unsupported-config`
303
+ (wrong environment) and `unsupported-input` (wrong message) are one
304
+ `NotSupportedError` upstream and two kinds here, because one is fixed by asking
305
+ for less and the other by sending something else.
306
+
307
+ **Mistakes are made unwritable, not guarded against.** `ModelAccess` carries
308
+ `open` only on the variants where opening can work, so "create a session on an
309
+ unavailable model" is not an error to check for at runtime — there is no
310
+ expression for it.
311
+
312
+ **A tag is a string, not an enum.** Every union here is discriminated by a
313
+ `kind`, and `AccessKind`, `FailureKind` and `UsageKind` name those sets for a
314
+ `Record` or a signature. They are derived aliases, so a new variant is in them
315
+ already, and they stay types: `"ready"` still passes where one is asked for, and
316
+ nothing of them reaches your bundle. An enum member would be a value, and a
317
+ nominal one — your own `"ready"` would stop being assignable to ours.
318
+
319
+ **A plain number is not a measurement.** `Tokens` and `Fraction` are separate
320
+ brands over `number`, because a ratio and a percentage (0.5 against 50) are both
321
+ numbers and swapping them is silent. A constructor that validates is the only way
322
+ in.
323
+
324
+ Four compiler flags beyond `strict` are load-bearing, `exactOptionalPropertyTypes`
325
+ most of all: without it `{ system: undefined }` passes as `SessionOptions`, and
326
+ every invariant that rests on an absent optional field falls apart.
327
+
328
+ ### The types have their own test
329
+
330
+ [`src/types.test-d.ts`](src/types.test-d.ts) is compiled, never run. Every line
331
+ that must **not** compile carries a `@ts-expect-error`, and TypeScript reports
332
+ `TS2578: Unused '@ts-expect-error' directive` when the expected error fails to
333
+ appear. So the file builds exactly while each listed state stays
334
+ unrepresentable — loosen a type and the build breaks.
335
+
336
+ It covers twelve of them:
337
+
338
+ 1. A session cannot be opened on an unavailable model
339
+ 2. A result cannot be used without handling the failure
340
+ 3. Each failure carries only its own fields
341
+ 4. An unbounded window cannot be subtracted from by accident
342
+ 5. A system turn cannot be smuggled into the history
343
+ 6. A schema is not just any object
344
+ 7. Availability is asked for a specific request
345
+ 8. The provider list is the app's, and switches over it stay exhaustive
346
+ 9. The stream stays a stream
347
+ 10. The monitor is the platform's, and ours passes for it
348
+ 11. A kind alias names the set without closing it to literals
349
+ 12. The record is a snapshot, not a handle
350
+
351
+ It falls under the project tsconfig's `include`, so `npm run typecheck` checks
352
+ it. Vitest deliberately misses it — `include` there is `*.test.ts`.
353
+
354
+ ## Contributing
355
+
356
+ Conventional commits — release-please reads them — and a pull request against
357
+ `main`; CI is the reviewer that has to say yes first. Everything it runs, runs
358
+ locally:
359
+
360
+ ```sh
361
+ npm ci
362
+ npm run typecheck && npm run lint && npm run format:check && npm test
363
+ npm run test:e2e # Chromium; the demo server starts itself
364
+ npm run external && npm run external:orchestrator && npm run external:agent
365
+ ```
366
+
367
+ A backend is the most useful thing to bring. Four answers, `createProvider`,
368
+ and `describeContract` green — see [Bring your own backend](#bring-your-own-backend).
369
+
370
+ ## License
371
+
372
+ [MIT](LICENSE)
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The abort mechanics every provider shares: one session-wide signal mixed
3
+ * into every generation, so a close ends the in-flight calls and the later
4
+ * ones alike.
5
+ */
6
+ import type { AiFailure } from "../types/failures.js";
7
+ export declare function abortFailure(signal: AbortSignal): AiFailure;
8
+ /**
9
+ * Skips `AbortSignal.any` when there is nothing to link: `any([s])` allocates
10
+ * a fresh signal and a listener on the original, for nothing. The `reason`
11
+ * survives either way — it propagates by identity, not by copy.
12
+ */
13
+ export declare const linkSignals: (sessionSignal: AbortSignal, callerSignal?: AbortSignal) => AbortSignal;
14
+ //# sourceMappingURL=abort.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abort.d.ts","sourceRoot":"","sources":["../../src/helpers/abort.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAO3D;AAED;;;;GAIG;AACH,eAAO,MAAM,WAAW,GACtB,eAAe,WAAW,EAC1B,eAAe,WAAW,KACzB,WAGiD,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The abort mechanics every provider shares: one session-wide signal mixed
3
+ * into every generation, so a close ends the in-flight calls and the later
4
+ * ones alike.
5
+ */
6
+ export function abortFailure(signal) {
7
+ const reason = signal.reason;
8
+ return {
9
+ kind: "aborted",
10
+ reason: reason instanceof Error ? reason.message : String(reason),
11
+ cause: reason,
12
+ };
13
+ }
14
+ /**
15
+ * Skips `AbortSignal.any` when there is nothing to link: `any([s])` allocates
16
+ * a fresh signal and a listener on the original, for nothing. The `reason`
17
+ * survives either way — it propagates by identity, not by copy.
18
+ */
19
+ export const linkSignals = (sessionSignal, callerSignal) => callerSignal === undefined
20
+ ? sessionSignal
21
+ : AbortSignal.any([sessionSignal, callerSignal]);
22
+ //# sourceMappingURL=abort.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abort.js","sourceRoot":"","sources":["../../src/helpers/abort.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,UAAU,YAAY,CAAC,MAAmB;IAC9C,MAAM,MAAM,GAAY,MAAM,CAAC,MAAM,CAAC;IACtC,OAAO;QACL,IAAI,EAAE,SAAS;QACf,MAAM,EAAE,MAAM,YAAY,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC;QACjE,KAAK,EAAE,MAAM;KACd,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CACzB,aAA0B,EAC1B,YAA0B,EACb,EAAE,CACf,YAAY,KAAK,SAAS;IACxB,CAAC,CAAC,aAAa;IACf,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,aAAa,EAAE,YAAY,CAAC,CAAC,CAAC"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A session is idle, generating, or closed: one cell, not three flags.
3
+ *
4
+ * A `running` name beside an `aborted` signal beside a `disposed` boolean lets
5
+ * every combination compile — closed but busy, a turn with no signal to end
6
+ * it, an `end()` from a turn abandoned two turns ago freeing the one running
7
+ * now. Here the phase is a union, the running phase carries the signal that
8
+ * ends it, and it is its own token: `end` compares identity, so a stale hook
9
+ * has nothing to free.
10
+ *
11
+ * Shared rather than written per provider, for the reason `overflow.ts` is
12
+ * shared: the contract suite would see each one refuse and never notice they
13
+ * refuse different things.
14
+ */
15
+ import { type Result } from "../types/foundations.js";
16
+ import type { AiFailure } from "../types/failures.js";
17
+ /** The two contract calls that generate; `usage` and `close` never conflict. */
18
+ export type GeneratingCall = "prompt" | "promptStream";
19
+ /** Proof that the door was open: the only way to hold one is `begin` agreeing. */
20
+ export interface RunningTurn {
21
+ readonly callName: GeneratingCall;
22
+ /** The session's own abort linked with the caller's; hand this to the backend. */
23
+ readonly signal: AbortSignal;
24
+ }
25
+ export interface SessionLifetime {
26
+ /** A running turn, or what this call owes the caller: `busy` while another runs, `aborted` once closed. */
27
+ readonly begin: (callName: GeneratingCall, signal?: AbortSignal) => Result<RunningTurn, AiFailure>;
28
+ /** Ignored unless `turn` is the one running, which is what keeps an abandoned turn from freeing its successor. */
29
+ readonly end: (turn: RunningTurn) => void;
30
+ /** `closed-now` once per session; the caller releases the model on that one. */
31
+ readonly close: () => "closed-now" | "already-closed";
32
+ }
33
+ export declare function createSessionLifetime(): SessionLifetime;
34
+ //# sourceMappingURL=lifetime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifetime.d.ts","sourceRoot":"","sources":["../../src/helpers/lifetime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGtD,gFAAgF;AAChF,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,cAAc,CAAC;AAEvD,kFAAkF;AAClF,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;IAClC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAYD,MAAM,WAAW,eAAe;IAC9B,2GAA2G;IAC3G,QAAQ,CAAC,KAAK,EAAE,CACd,QAAQ,EAAE,cAAc,EACxB,MAAM,CAAC,EAAE,WAAW,KACjB,MAAM,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;IACpC,kHAAkH;IAClH,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,CAAC;IAC1C,gFAAgF;IAChF,QAAQ,CAAC,KAAK,EAAE,MAAM,YAAY,GAAG,gBAAgB,CAAC;CACvD;AAaD,wBAAgB,qBAAqB,IAAI,eAAe,CA2CvD"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A session is idle, generating, or closed: one cell, not three flags.
3
+ *
4
+ * A `running` name beside an `aborted` signal beside a `disposed` boolean lets
5
+ * every combination compile — closed but busy, a turn with no signal to end
6
+ * it, an `end()` from a turn abandoned two turns ago freeing the one running
7
+ * now. Here the phase is a union, the running phase carries the signal that
8
+ * ends it, and it is its own token: `end` compares identity, so a stale hook
9
+ * has nothing to free.
10
+ *
11
+ * Shared rather than written per provider, for the reason `overflow.ts` is
12
+ * shared: the contract suite would see each one refuse and never notice they
13
+ * refuse different things.
14
+ */
15
+ import { err, ok } from "../types/foundations.js";
16
+ import { abortFailure, linkSignals } from "./abort.js";
17
+ /** No signal to read a reason from: closing is not an abort with a cause, it is the end of the session. */
18
+ const CLOSED_FAILURE = {
19
+ kind: "aborted",
20
+ reason: "the session is closed",
21
+ };
22
+ const busyFailure = (callName) => ({
23
+ kind: "busy",
24
+ detail: `${callName} is already running on this session`,
25
+ });
26
+ export function createSessionLifetime() {
27
+ let phase = { kind: "idle" };
28
+ return {
29
+ begin: (callName, signal) => {
30
+ switch (phase.kind) {
31
+ case "closed":
32
+ return err(CLOSED_FAILURE);
33
+ case "generating":
34
+ return err(busyFailure(phase.turn.callName));
35
+ case "idle": {
36
+ // The caller's own signal, not the linked one: its reason is the one
37
+ // worth reporting, and linking an aborted signal only to read it back
38
+ // is work for nothing.
39
+ if (signal?.aborted === true)
40
+ return err(abortFailure(signal));
41
+ const abortController = new AbortController();
42
+ const turn = {
43
+ callName,
44
+ signal: linkSignals(abortController.signal, signal),
45
+ };
46
+ phase = { kind: "generating", turn, abortController };
47
+ return ok(turn);
48
+ }
49
+ }
50
+ },
51
+ end: (turn) => {
52
+ if (phase.kind !== "generating")
53
+ return;
54
+ if (phase.turn !== turn)
55
+ return;
56
+ phase = { kind: "idle" };
57
+ },
58
+ close: () => {
59
+ if (phase.kind === "closed")
60
+ return "already-closed";
61
+ // Moved before the abort: the listeners run synchronously, and one of
62
+ // them reaching back in must find a closed session rather than this one.
63
+ const previousPhase = phase;
64
+ phase = { kind: "closed" };
65
+ if (previousPhase.kind === "generating")
66
+ previousPhase.abortController.abort();
67
+ return "closed-now";
68
+ },
69
+ };
70
+ }
71
+ //# sourceMappingURL=lifetime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifetime.js","sourceRoot":"","sources":["../../src/helpers/lifetime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,GAAG,EAAE,EAAE,EAAe,MAAM,yBAAyB,CAAC;AAE/D,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAkCvD,2GAA2G;AAC3G,MAAM,cAAc,GAAc;IAChC,IAAI,EAAE,SAAS;IACf,MAAM,EAAE,uBAAuB;CAChC,CAAC;AAEF,MAAM,WAAW,GAAG,CAAC,QAAwB,EAAa,EAAE,CAAC,CAAC;IAC5D,IAAI,EAAE,MAAM;IACZ,MAAM,EAAE,GAAG,QAAQ,qCAAqC;CACzD,CAAC,CAAC;AAEH,MAAM,UAAU,qBAAqB;IACnC,IAAI,KAAK,GAAU,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAEpC,OAAO;QACL,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE;YAC1B,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;gBACnB,KAAK,QAAQ;oBACX,OAAO,GAAG,CAAC,cAAc,CAAC,CAAC;gBAC7B,KAAK,YAAY;oBACf,OAAO,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;gBAC/C,KAAK,MAAM,CAAC,CAAC,CAAC;oBACZ,qEAAqE;oBACrE,sEAAsE;oBACtE,uBAAuB;oBACvB,IAAI,MAAM,EAAE,OAAO,KAAK,IAAI;wBAAE,OAAO,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;oBAC/D,MAAM,eAAe,GAAG,IAAI,eAAe,EAAE,CAAC;oBAC9C,MAAM,IAAI,GAAG;wBACX,QAAQ;wBACR,MAAM,EAAE,WAAW,CAAC,eAAe,CAAC,MAAM,EAAE,MAAM,CAAC;qBACpD,CAAC;oBACF,KAAK,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,eAAe,EAAE,CAAC;oBACtD,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC;gBAClB,CAAC;YACH,CAAC;QACH,CAAC;QAED,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;YACZ,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY;gBAAE,OAAO;YACxC,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI;gBAAE,OAAO;YAChC,KAAK,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;QAC3B,CAAC;QAED,KAAK,EAAE,GAAG,EAAE;YACV,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ;gBAAE,OAAO,gBAAgB,CAAC;YACrD,sEAAsE;YACtE,yEAAyE;YACzE,MAAM,aAAa,GAAG,KAAK,CAAC;YAC5B,KAAK,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;YAC3B,IAAI,aAAa,CAAC,IAAI,KAAK,YAAY;gBACrC,aAAa,CAAC,eAAe,CAAC,KAAK,EAAE,CAAC;YACxC,OAAO,YAAY,CAAC;QACtB,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Download progress as the platform reports it: an `EventTarget` that fires
3
+ * `downloadprogress`.
4
+ *
5
+ * The Prompt API hands `create()` a `CreateMonitor` and fires a `ProgressEvent`
6
+ * on it. This is the same object for a provider with no browser behind it, and
7
+ * both satisfy `DownloadMonitor` from `../types/session.ts` —
8
+ * `src/types.test-d.ts` checks the ambient declaration against it.
9
+ *
10
+ * `ProgressEvent` is not a global in Node 22 — `typeof ProgressEvent` is
11
+ * `"undefined"` there, while `Event`, `EventTarget` and `CustomEvent` are
12
+ * present — and the vitest suites run in the node environment. So the event is
13
+ * a subclass of `Event` carrying the three `ProgressEvent` fields, which is
14
+ * what a listener reads. `CustomEvent` was the alternative and puts the numbers
15
+ * behind `detail`, where a listener written against the spec would not look.
16
+ */
17
+ import type { Fraction } from "../types/foundations.js";
18
+ import type { DownloadMonitor } from "../types/session.js";
19
+ export declare const DOWNLOAD_PROGRESS = "downloadprogress";
20
+ /**
21
+ * `total` is 1 and `loaded` is the ratio. MDN names the normalization on
22
+ * `ProgressEvent` itself ("if using 1 as a total, then loaded would be a
23
+ * decimal value between 0 and 1"), and it settles the one thing the Prompt API
24
+ * docs disagree about: `e.loaded / e.total` and a bare `e.loaded` are the same
25
+ * number here.
26
+ */
27
+ export declare class DownloadProgressEvent extends Event {
28
+ readonly loaded: Fraction;
29
+ readonly lengthComputable = true;
30
+ readonly total = 1;
31
+ constructor(loaded: Fraction);
32
+ }
33
+ /**
34
+ * Our `CreateMonitor`. `report` is not part of `DownloadMonitor`, so a caller
35
+ * holding the contract type can listen but cannot invent progress —
36
+ * `src/types.test-d.ts` checks that it cannot.
37
+ */
38
+ export declare class ProgressMonitor extends EventTarget implements DownloadMonitor {
39
+ #private;
40
+ /**
41
+ * An `onevent` property is not free with `EventTarget`: only interfaces the
42
+ * platform defines have one, and it is a listener registration behind an
43
+ * accessor — assigning twice replaces, assigning null unsubscribes. Here it
44
+ * also carries the event's type, which `addEventListener` cannot: see the
45
+ * note on `DownloadMonitor`.
46
+ */
47
+ get ondownloadprogress(): ((event: ProgressEvent) => void) | null;
48
+ set ondownloadprogress(handler: ((event: ProgressEvent) => void) | null);
49
+ /** Dropped rather than dispatched when it does not move forward — the contract's non-decreasing guarantee. */
50
+ report(loaded: Fraction): void;
51
+ }
52
+ //# sourceMappingURL=monitor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"monitor.d.ts","sourceRoot":"","sources":["../../src/helpers/monitor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACxD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D,eAAO,MAAM,iBAAiB,qBAAqB,CAAC;AAEpD;;;;;;GAMG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAIlC,QAAQ,CAAC,MAAM,EAAE,QAAQ;IAHrC,QAAQ,CAAC,gBAAgB,QAAQ;IACjC,QAAQ,CAAC,KAAK,KAAK;gBAEE,MAAM,EAAE,QAAQ;CAGtC;AAED;;;;GAIG;AACH,qBAAa,eAAgB,SAAQ,WAAY,YAAW,eAAe;;IAIzE;;;;;;OAMG;IACH,IAAI,kBAAkB,IAAI,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC,GAAG,IAAI,CAEhE;IAED,IAAI,kBAAkB,CAAC,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC,GAAG,IAAI,EAWtE;IAED,8GAA8G;IAC9G,MAAM,CAAC,MAAM,EAAE,QAAQ,GAAG,IAAI;CAK/B"}