feat(ai,durable): add lightweight pi-ai/models entry and avoid TypeBox in durable

Durable generation imports retry helpers from pi-ai/utils/retry; examples use
pi-ai/models and pi-ai/providers/faux. Entry graph budgets and an import
footprint test guard the models entry.
This commit is contained in:
Mario Zechner 2026-09-29 15:24:05 +02:00
parent 0a7a4eca68
commit ba7d5fbedb
17 changed files with 107 additions and 31 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added the lightweight `@earendil-works/pi-ai/models` entry point for model collections and provider construction without loading TypeBox, built-in catalogs, or provider SDKs.
## [0.99.1] - 2026-09-29
### Added

View file

@ -1653,10 +1653,10 @@ Browser compatibility notes:
## Bundling and Tree Shaking
For small bundles, import only the providers you need:
For small bundles and low-overhead unbundled scripts, import the model runtime and only the providers you need:
```typescript
import { createModels } from '@earendil-works/pi-ai';
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
const models = createModels();
@ -1665,7 +1665,8 @@ models.setProvider(openaiProvider());
Rules:
- `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, provider factories, or SDK implementations.
- `@earendil-works/pi-ai/models` exports the model runtime (`createModels`, `createProvider`, model helpers, and their types) without TypeBox, built-in catalogs, or SDK implementations. Other types can still use `import type` from the root.
- `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports TypeBox and schema validation. Unbundled Node scripts do not tree-shake its unused exports; prefer `./models`, `./providers/faux`, and specific `./utils/*` subpaths when those are all you need.
- `@earendil-works/pi-ai/providers/<provider>` imports that provider's catalog and lazy API wrapper only.
- `@earendil-works/pi-ai/providers/all` imports every built-in provider factory and all catalogs. Use it only when you want the full built-in set.
- With code splitting, provider SDKs stay in lazy chunks and load on first request.

View file

@ -15,6 +15,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./models": {
"types": "./dist/models.d.ts",
"import": "./dist/models.js"
},
"./compat": {
"types": "./dist/compat.d.ts",
"import": "./dist/compat.js"

View file

@ -0,0 +1,58 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
const sourceEntry = new URL("../src/models.ts", import.meta.url).href;
// Install before linking the static imports, so unused barrel re-exports are checked too.
const preload = `data:text/javascript,${encodeURIComponent(`
import { registerHooks } from "node:module";
const forbidden = [
"/node_modules/typebox/",
"/node_modules/@anthropic-ai/sdk/",
"/node_modules/openai/",
"/node_modules/@google/genai/",
"/node_modules/@aws-sdk/",
"/providers/data/",
"/models.generated.",
];
registerHooks({
load(url, context, nextLoad) {
if (forbidden.some((part) => url.includes(part))) {
throw new Error("Heavy dependency loaded by models entry: " + url);
}
return nextLoad(url, context);
},
});
`)}`;
describe("lightweight models entry", () => {
it.each([sourceEntry, "@earendil-works/pi-ai/models"])(
"runs a faux completion without TypeBox, catalogs, or SDKs through %s",
(entry) => {
const script = `
import assert from "node:assert/strict";
import { createModels, createProvider } from ${JSON.stringify(entry)};
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
assert.equal(typeof createProvider, "function");
const models = createModels();
assert.deepEqual(models.getModels(), []);
assert.deepEqual(models.getProviders(), []);
const faux = fauxProvider();
models.setProvider(faux.provider);
faux.setResponses([fauxAssistantMessage("OK")]);
const response = await models.completeSimple(faux.getModel(), { messages: [] });
assert.equal(response.stopReason, "stop");
assert.deepEqual(response.content, [{ type: "text", text: "OK" }]);
`;
const result = spawnSync(process.execPath, ["--import", preload, "--input-type=module", "--eval", script], {
cwd: packageRoot,
encoding: "utf8",
timeout: 10_000,
});
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
},
);
});

View file

@ -55,6 +55,7 @@
### Fixed
- Fixed cached documents skipping migration when accessed with a newer definition version, and older definitions reading values migrated only in memory. Document states and watches hydrated under another definition version receive the new value as a root replacement.
- Avoided loading TypeBox through the package root's generation retry helpers and switched examples to narrow pi-ai model and faux-provider imports.
## [0.87.1] - 2026-09-22

View file

@ -1,15 +1,14 @@
import { type Context, copyJson, type Draft, type JsonValue } from "@earendil-works/chord";
import {
type Api,
type AssistantMessage,
type DeferredHandle,
isRetryableAssistantError,
type Message,
type Model,
type ModelThinkingLevel,
retryDelayMs,
type SimpleStreamOptions,
import type {
Api,
AssistantMessage,
DeferredHandle,
Message,
Model,
ModelThinkingLevel,
SimpleStreamOptions,
} from "@earendil-works/pi-ai";
import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/utils/retry";
import { AssistantEntry, SystemEntry } from "../entries.ts";
import { defineTask } from "../tasks.ts";
import type { ConversationId, EntryId, NextTaskState, TaskRuntime, Tx } from "../types.ts";

View file

@ -2,7 +2,8 @@
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/06-harness.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels, Type } from "@earendil-works/pi-ai";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import {
ConversationConfig,
createRegistry,

View file

@ -2,7 +2,8 @@
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/07-configuration.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels, Type } from "@earendil-works/pi-ai";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness, MemoryStorage, type ToolRegistration } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;

View file

@ -2,7 +2,7 @@
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/08-harness-conversations.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { ConversationConfig, createRegistry, defineEntry, Harness, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;

View file

@ -2,13 +2,8 @@
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/09-context.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
type AssistantMessage,
createModels,
type Message,
type StopReason,
type ToolResultMessage,
} from "@earendil-works/pi-ai";
import type { AssistantMessage, Message, StopReason, ToolResultMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;

View file

@ -2,7 +2,7 @@
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/11-conversation-setup.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, defineDoc, Harness, LiveDoc, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;

View file

@ -2,7 +2,7 @@
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/12-tasks.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, defineTask, Harness, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;

View file

@ -5,7 +5,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, defineTask, Harness } from "../../src/index.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";

View file

@ -2,7 +2,8 @@
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/14-chat.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import { AssistantEntry, ConversationConfig, createRegistry, Harness, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;

View file

@ -2,7 +2,8 @@
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/15-system-prompt.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import {
type Conversation,
ConversationConfig,

View file

@ -2,7 +2,7 @@
// Run from packages/durable (needs OPENAI_API_KEY):
// node --conditions=source --experimental-strip-types test/examples/16-real-model.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { AssistantEntry, createRegistry, Harness, LiveDoc, MemoryStorage } from "../../src/index.ts";

View file

@ -30,13 +30,23 @@ const WORKSPACE = {
};
/**
* Budgets are deliberate. `.` and `./node` are batteries-included entries and stay unbounded; every
* narrow entry states the graph it is allowed to reach.
* Budgets are deliberate. Entries with no budget remain unbounded; each listed entry states the
* graph it is allowed to reach.
*/
const BUDGETS = {
"packages/ai": {
"./models": {
maxFiles: 15,
forbid: ["providers/", "models.generated.ts", "index.ts", "utils/validation.ts", "utils/typebox-helpers.ts"],
},
"./utils/*": { maxFiles: 3, forbid: ["providers/", "api/", "index.ts"] },
},
"packages/durable": {
".": {
maxFiles: 50,
forbid: ["packages/ai/src/index.ts", "packages/ai/src/utils/validation.ts", "packages/ai/src/utils/typebox-helpers.ts"],
},
},
"packages/agent": {
"./harness/runtime/reducer": { maxFiles: 1 },
"./harness/context": { maxFiles: 6, forbid: ["harness/runtime/", "harness/execution/", "packages/ai/"] },