Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ is room.
### Filling forms — from prose, then by talking to it

```ts
import { runFormAssist } from 'ai-kit';
import { runFormAssist } from 'ai-kit/forms';
import { useAiForm } from 'ai-kit/react';
import { createFormAssistHandler } from 'ai-kit/server';
```
Expand All @@ -129,6 +129,14 @@ stays its own package — it works, four apps run it, and it is useful well outs
this fleet. Swallowing it would have broken those four for the sake of a filing
system.

**Note the subpath.** Form filling is at `ai-kit/forms`, not at the root. For one
release it was both, and the first app to adopt the merged package paid for it:
`ai-forms` is ESM-only, so importing the *chain* from the root dragged the forms
package in behind it and the app's Jest run — which executes CJS — died inside a
module it never asked for. One install is still the whole promise; the exports
map is what keeps it, while letting a server that only wants a provider chain
stop paying for a form library.

React lives on its own subpath and is an **optional** peer, so importing `ai-kit`
on a server never pulls in a UI library.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ai-kit",
"version": "0.3.1",
"version": "0.4.0",
"description": "One install for the AI layer of an app: which model to call and what to do when the vendor retires it, how to read the three kinds of 429, a fair daily budget across users, and headless AI form filling.",
"license": "MIT",
"author": "Mao Nakamoto",
Expand Down
24 changes: 21 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ export {
utcDayKey,
} from "./fair-share.js";

// Form filling. Re-exported so that adding AI to an app is one install; the
// package itself stays independent and separately useful.
export * from "./forms.js";
// Form filling lives at `ai-kit/forms`, NOT here.
//
// It was re-exported from this root for one release, so that "adding AI" was a
// single import as well as a single install. The first app to adopt the merged
// package showed what that costs: `ai-forms` is ESM-only, so pulling it in from
// this root made every consumer of the CHAIN load the forms package too — and
// the app's Jest run, which executes CJS, died on `Unexpected token 'export'`
// inside a module it never asked for. The fix would have been a
// `transformIgnorePatterns` entry in that app, and in the next one, and in
// every app thereafter: one class of breakage, paid per repo, forever.
//
// One install is still the promise, and the exports map already keeps it:
//
// import { freeChain } from "ai-kit"; // the chain
// import { defineFields } from "ai-kit/forms"; // form filling
// import { useAssist } from "ai-kit/react"; // the React hook
//
// Same dependency, same version, nothing extra to install — a consumer just
// stops paying for the half it does not use. That is what subpath exports are
// for, and collapsing them into the root threw the benefit away.

45 changes: 32 additions & 13 deletions test/exports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,45 @@ test('the package exports its public surface through the exports map', () => {
});

/**
* The merge is the feature, so it needs a test.
* The merge is the feature, so it needs a test — but the feature is ONE INSTALL,
* not one import.
*
* `ai-kit` exists so that adding AI to an app is ONE install rather than four
* separate decisions — AOZ made one of those decisions (ai-forms), skipped the
* other two, and was taken down by the one it skipped. If the form-filling
* re-export silently stops resolving, the package quietly becomes the old
* ai-ration again under a friendlier name, and nothing else here would notice.
* `ai-kit` exists so that adding AI to an app is a single decision rather than
* four: AOZ made one of those decisions (ai-forms), skipped the other two, and
* was taken down by one it skipped. Everything below still ships from this one
* package at one version. What changed is WHERE from.
*/
test('form filling is reachable from the root, so one install covers it', async () => {
const pkg = await import('ai-kit');
test('form filling is reachable from the package, so one install covers it', async () => {
const forms = await import('ai-kit/forms');
for (const name of ['runFormAssist', 'defineFields', 'mergeValues', 'sanitizeValues']) {
assert.equal(typeof pkg[name], 'function', `missing re-export: ${name}`);
assert.equal(typeof forms[name], 'function', `missing export: ${name}`);
}
});

test('the model layer and the form layer coexist without shadowing', async () => {
/**
* ...and it must NOT be reachable from the root. This is a regression test with
* a scar behind it.
*
* For one release the root re-exported forms, so `import { freeChain } from
* 'ai-kit'` dragged `ai-forms` in behind it. That package is ESM-only, so the
* first adopting app's Jest run — which executes CJS — died on `Unexpected token
* 'export'` inside a module it had never asked for. The remedy would have been a
* `transformIgnorePatterns` entry in that app, then the next, then every app
* after: one class of breakage, paid per repo, forever.
*
* So the absence is the contract. A convenience re-export added back at the root
* would look harmless in review and break the next consumer the same way.
*/
test('the root does NOT drag the form layer in behind the chain', async () => {
const pkg = await import('ai-kit');
// One from each half. A collision would drop one silently at build time.
assert.equal(typeof pkg.freeChain, 'function');
assert.equal(typeof pkg.runFormAssist, 'function');
assert.equal(typeof pkg.freeChain, 'function', 'the chain belongs at the root');
for (const name of ['runFormAssist', 'defineFields']) {
assert.equal(
name in pkg,
false,
`${name} is re-exported from the root again — a chain-only consumer now loads ai-forms`,
);
}
});

test('./forms resolves through the exports map', async () => {
Expand Down