@zerotal/ai 1.5.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/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +67 -0
- package/src/AiFake.ts +351 -0
- package/src/AiGenerationJob.ts +76 -0
- package/src/AiManager.ts +608 -0
- package/src/agentLoop.ts +151 -0
- package/src/commands/AiSpendCommand.ts +58 -0
- package/src/commands/AiTestCommand.ts +61 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +293 -0
- package/src/drivers/AiDriver.ts +104 -0
- package/src/drivers/AnthropicDriver.ts +483 -0
- package/src/drivers/OllamaDriver.ts +286 -0
- package/src/drivers/OpenAiDriver.ts +344 -0
- package/src/drivers/anthropic-sdk.ts +126 -0
- package/src/drivers/embeddings/EmbeddingsDriver.ts +20 -0
- package/src/drivers/embeddings/OllamaEmbeddingsDriver.ts +60 -0
- package/src/drivers/embeddings/OpenAiEmbeddingsDriver.ts +69 -0
- package/src/drivers/sse.ts +107 -0
- package/src/errors.ts +129 -0
- package/src/events.ts +51 -0
- package/src/facades/Ai.ts +3 -0
- package/src/global.d.ts +8 -0
- package/src/index.ts +106 -0
- package/src/monitor.ts +179 -0
- package/src/observability.ts +130 -0
- package/src/pricing.ts +90 -0
- package/src/provider/AiProvider.ts +65 -0
- package/src/redact.ts +32 -0
- package/src/schema.ts +292 -0
- package/src/spend.ts +100 -0
- package/src/stats.ts +121 -0
- package/src/tool.ts +98 -0
- package/src/types.ts +317 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Changelog — @zerotal/ai
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `experimental`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.5.0] — 2026-08-15
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **The package.** `Ai.text()`, `Ai.stream()`, `Ai.object()`, `Ai.agent()`, and
|
|
16
|
+
`Ai.embed()` behind one facade, with the provider chosen in `config/ai.ts`
|
|
17
|
+
rather than at each call site. Three drivers ship: Anthropic (via the optional
|
|
18
|
+
`@anthropic-ai/sdk` peer), OpenAI, and Ollama (both over plain `fetch`).
|
|
19
|
+
|
|
20
|
+
- **One agent loop, shared by every driver.** `AiDriver.agent()` is optional and
|
|
21
|
+
none of the three implement it — they all run the same loop over `text()`. An
|
|
22
|
+
abstraction whose second implementation reuses none of the first is not an
|
|
23
|
+
abstraction, and the shared suite in `drivers.test.ts` runs the same
|
|
24
|
+
assertions against all three so that stays true.
|
|
25
|
+
|
|
26
|
+
- **`pause_turn` is resumed, not mistaken for an answer.** A provider running a
|
|
27
|
+
long server-side tool can end a turn with `stop_reason: "pause_turn"`, meaning
|
|
28
|
+
"ask me again". It is neither an error nor a completion, so an unhandled one
|
|
29
|
+
reads as a finished answer and the user sees a silently truncated response
|
|
30
|
+
with no warning anywhere. The loop pushes the paused turn back and re-requests,
|
|
31
|
+
capped by `agent.maxResumes`.
|
|
32
|
+
|
|
33
|
+
- **Refusals are typed, and checked before the content is read.** A declined
|
|
34
|
+
request arrives as a **successful HTTP 200** with empty or partial content, so
|
|
35
|
+
code that reads `content[0]` first crashes on a response the API considers
|
|
36
|
+
fine. `AiRefusedError` carries the provider's category and, for a mid-stream
|
|
37
|
+
decline, the partial text — so a caller can discard a truncated answer
|
|
38
|
+
knowingly. Anthropic's server-side `fallbacks: "default"` is on by default.
|
|
39
|
+
|
|
40
|
+
- **Schema translation that decides, rather than hoping.** Structured output
|
|
41
|
+
accepts a narrow JSON Schema subset — `additionalProperties: false` required on
|
|
42
|
+
every object, no length or numeric bounds, no recursion — and rejects anything
|
|
43
|
+
else at _request_ time. So `translateSchema()` strips what it cannot express
|
|
44
|
+
and `recheckAgainstSchema()` re-applies it with the validator we already own;
|
|
45
|
+
a recursive schema, which has no client-side rescue, is refused at definition
|
|
46
|
+
time instead. `schema.test.ts` pins the exact output for every supported rule.
|
|
47
|
+
|
|
48
|
+
- **A refreshable lock on named agent runs.** `Ai.agent({ lock: "refund:4821" })`
|
|
49
|
+
is exclusive for that name, refreshes for as long as the loop runs, and aborts
|
|
50
|
+
the loop's signal if the lock is ever lost. Opt-in and named on purpose: a
|
|
51
|
+
shared key would serialize every agent run in the app.
|
|
52
|
+
|
|
53
|
+
- **Spend ceilings, prompt redaction, and a monitor section.**
|
|
54
|
+
`limits.perRequestUsd` is checked before the request is sent, from a real token
|
|
55
|
+
count via the provider's own tokenizer — never `tiktoken`, which undercounts
|
|
56
|
+
Claude by 15–20%. Prompts are redacted in logs and the monitor by default,
|
|
57
|
+
because a prompt is user data and observability is where it would otherwise be
|
|
58
|
+
durably kept. The monitor's **AI** section shows spend against the ceiling,
|
|
59
|
+
tokens, latency percentiles per model, and the refusal rate.
|
|
60
|
+
|
|
61
|
+
- **`AiFake`** — `respondWith()`, `respondWithObject()`, `refuse()`, and
|
|
62
|
+
`assertPrompted()` / `assertPromptCount()` / `assertSystemPrompted()`. The
|
|
63
|
+
assertions are about what the application asked for, which is the part that can
|
|
64
|
+
be wrong. The suite passes with no API key set.
|
|
65
|
+
|
|
66
|
+
- **Commands.** `zt ai:test [driver]` reaches the provider once and prints the
|
|
67
|
+
resolved model; `zt ai:spend` reports this process's token spend by model.
|
|
68
|
+
|
|
69
|
+
### Notes
|
|
70
|
+
|
|
71
|
+
- `temperature` is accepted on the request surface but **dropped** by the
|
|
72
|
+
Anthropic driver, because current Claude models reject it with a 400 and a
|
|
73
|
+
parameter forwarded blindly would fail every request. `validateAiConfig()`
|
|
74
|
+
warns when a configured model rejects it. Use `effort` instead.
|
|
75
|
+
- The daily spend ceiling is in-process: N workers hold N ceilings, and the
|
|
76
|
+
figures are estimated from public list prices. It is a guard against a runaway
|
|
77
|
+
loop, not a billing system.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
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,111 @@
|
|
|
1
|
+
# @zerotal/ai
|
|
2
|
+
|
|
3
|
+
> Text, streaming, structured output, typed tools, and an agent loop — behind one provider-agnostic facade.
|
|
4
|
+
|
|
5
|
+
One way to talk to a language model: `Ai.text()` for a completion, `Ai.stream()` for tokens as they arrive, `Ai.object()` for a value that satisfies a validator schema, and `Ai.agent()` for a loop that calls your tools until the model is done. The provider is chosen in `config/ai.ts`, not at each call site.
|
|
6
|
+
|
|
7
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
8
|
+
|
|
9
|
+
**Maturity: `experimental`** — the API may change in a minor release.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @zerotal/ai
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Provider SDKs are optional peers, imported lazily. Install only the one you use:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bun add @anthropic-ai/sdk # only for the anthropic driver
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The OpenAI and Ollama drivers use `fetch` directly and need nothing extra.
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
Register the provider in `bootstrap/providers.ts`:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { AiProvider } from "@zerotal/ai";
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then configure it:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// config/ai.ts
|
|
37
|
+
import { AiConfig } from "@zerotal/ai";
|
|
38
|
+
|
|
39
|
+
export default AiConfig({
|
|
40
|
+
default: "anthropic",
|
|
41
|
+
drivers: {
|
|
42
|
+
anthropic: { apiKey: Bun.env["ANTHROPIC_API_KEY"] ?? "" },
|
|
43
|
+
},
|
|
44
|
+
limits: { perRequestUsd: 0.5, perDayUsd: 25 },
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { Ai, tool } from "@zerotal/ai";
|
|
52
|
+
|
|
53
|
+
// A completion.
|
|
54
|
+
const summary = await Ai.text(`Summarize in one sentence:\n\n${article}`);
|
|
55
|
+
|
|
56
|
+
// Tokens as they arrive, cancellable.
|
|
57
|
+
for await (const chunk of Ai.stream({ prompt, signal })) {
|
|
58
|
+
if (chunk.type === "text") process.stdout.write(chunk.text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A value that satisfies a validator schema — the same schema you use for forms.
|
|
62
|
+
const review = await Ai.object({ prompt }, (rule) => ({
|
|
63
|
+
sentiment: rule.string().in(["positive", "neutral", "negative"]),
|
|
64
|
+
score: rule.number().min(1).max(5),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// A tool-calling loop, exclusive for the work it names.
|
|
68
|
+
const lookupOrder = tool({
|
|
69
|
+
name: "lookup_order",
|
|
70
|
+
description: "Fetch one order by id. Call this whenever the user mentions an order number.",
|
|
71
|
+
input: (rule) => ({ id: rule.string() }),
|
|
72
|
+
handle: async ({ id }) => await Order.find(id),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = await Ai.agent({
|
|
76
|
+
prompt: "Where is order 4821?",
|
|
77
|
+
tools: [lookupOrder],
|
|
78
|
+
lock: "order:4821",
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Testing
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { AiFake } from "@zerotal/ai";
|
|
86
|
+
|
|
87
|
+
const ai = AiFake.install();
|
|
88
|
+
ai.respondWith("A one-sentence summary.");
|
|
89
|
+
|
|
90
|
+
await service.summarize(article);
|
|
91
|
+
|
|
92
|
+
ai.assertPrompted(/Summarize/);
|
|
93
|
+
ai.restore();
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
No API key, no network. The suite for this package passes with neither.
|
|
97
|
+
|
|
98
|
+
## Commands
|
|
99
|
+
|
|
100
|
+
| Command | What it does |
|
|
101
|
+
| --------------------- | -------------------------------------------------------- |
|
|
102
|
+
| `zt ai:test [driver]` | Reach the provider once and print the **resolved model** |
|
|
103
|
+
| `zt ai:spend` | This process's token spend today, by model |
|
|
104
|
+
|
|
105
|
+
## Documentation
|
|
106
|
+
|
|
107
|
+
Full documentation: [zerotal.dev/docs/ai](https://zerotal.dev/docs/ai).
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/ai",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "experimental",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/*.test.ts",
|
|
17
|
+
"!src/**/*.test.tsx",
|
|
18
|
+
"!src/**/*.spec.ts",
|
|
19
|
+
"!src/**/__fixtures__/**"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.3.14"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@zerotal/core": "1.5.0",
|
|
33
|
+
"@zerotal/validator": "1.5.0",
|
|
34
|
+
"@zerotal/queue": "1.5.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@anthropic-ai/sdk": ">=0.70.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@anthropic-ai/sdk": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"typescript": "^5.8.0"
|
|
46
|
+
},
|
|
47
|
+
"description": "Provider-agnostic AI generation for Zerotal — text, streaming, structured output, typed tools, and an agent loop.",
|
|
48
|
+
"keywords": [
|
|
49
|
+
"zerotal",
|
|
50
|
+
"bun",
|
|
51
|
+
"typescript",
|
|
52
|
+
"framework",
|
|
53
|
+
"ai",
|
|
54
|
+
"llm",
|
|
55
|
+
"anthropic",
|
|
56
|
+
"claude",
|
|
57
|
+
"openai",
|
|
58
|
+
"ollama"
|
|
59
|
+
],
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
63
|
+
"directory": "packages/ai"
|
|
64
|
+
},
|
|
65
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/ai#readme",
|
|
66
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
67
|
+
}
|
package/src/AiFake.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { Application, currentApp } from "@zerotal/core";
|
|
2
|
+
import { AiRefusedError } from "./errors.ts";
|
|
3
|
+
import { normalizeMessages, promptText, type DriverStatus } from "./drivers/AiDriver.ts";
|
|
4
|
+
import type { AiAgentRequest, AiQueueHandler, AiQueueOptions } from "./AiManager.ts";
|
|
5
|
+
import type {
|
|
6
|
+
AiAgentResult,
|
|
7
|
+
AiEmbedRequest,
|
|
8
|
+
AiEmbedResponse,
|
|
9
|
+
AiRequest,
|
|
10
|
+
AiResponse,
|
|
11
|
+
AiStreamChunk,
|
|
12
|
+
AiUsage,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
type Binding = unknown;
|
|
16
|
+
|
|
17
|
+
/** One recorded call. */
|
|
18
|
+
export interface CapturedGeneration {
|
|
19
|
+
/** `"text" | "stream" | "object" | "agent" | "embed" | "queue"`. */
|
|
20
|
+
operation: string;
|
|
21
|
+
/** The last user turn, unredacted — this is a test, and it is in memory. */
|
|
22
|
+
prompt: string;
|
|
23
|
+
system: string | undefined;
|
|
24
|
+
request: AiRequest;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the fake returns when nothing more specific was queued. */
|
|
28
|
+
const DEFAULT_TEXT = "This is a fake AI response.";
|
|
29
|
+
|
|
30
|
+
const NO_USAGE: AiUsage = {
|
|
31
|
+
inputTokens: 0,
|
|
32
|
+
outputTokens: 0,
|
|
33
|
+
cacheReadTokens: 0,
|
|
34
|
+
cacheWriteTokens: 0,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Drop-in replacement for {@link AiManager} that records prompts and answers
|
|
39
|
+
* from a script, instead of calling a provider.
|
|
40
|
+
*
|
|
41
|
+
* This is the piece that makes an AI feature testable at all: the assertions are
|
|
42
|
+
* about *what your application asked for*, which is the part you wrote and the
|
|
43
|
+
* part that can be wrong. Whether the model's prose is good is not a unit test.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const ai = AiFake.install();
|
|
47
|
+
* ai.respondWith("A one-sentence summary.");
|
|
48
|
+
*
|
|
49
|
+
* await service.summarize(article);
|
|
50
|
+
*
|
|
51
|
+
* ai.assertPrompted(/Summarize/);
|
|
52
|
+
* ai.assertPromptCount(1);
|
|
53
|
+
* ai.restore(); // in afterEach
|
|
54
|
+
*/
|
|
55
|
+
export class AiFake {
|
|
56
|
+
private readonly _calls: CapturedGeneration[] = [];
|
|
57
|
+
private readonly _texts: string[] = [];
|
|
58
|
+
private readonly _objects: unknown[] = [];
|
|
59
|
+
private readonly _handlers = new Map<string, AiQueueHandler>();
|
|
60
|
+
private _refuseWith: { category: string | null; explanation: string | null } | undefined;
|
|
61
|
+
|
|
62
|
+
private constructor(
|
|
63
|
+
private readonly _app: Application,
|
|
64
|
+
private readonly _original: Binding,
|
|
65
|
+
) {}
|
|
66
|
+
|
|
67
|
+
/** Replace the `ai` container binding with this fake. */
|
|
68
|
+
static install(): AiFake {
|
|
69
|
+
const app = currentApp();
|
|
70
|
+
const original = app.container.registry.get("ai");
|
|
71
|
+
const fake = new AiFake(app, original);
|
|
72
|
+
app.container.value("ai", fake);
|
|
73
|
+
return fake;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Restore the original `ai` binding. Call in `afterEach`. */
|
|
77
|
+
restore(): void {
|
|
78
|
+
if (this._original !== undefined) {
|
|
79
|
+
this._app.container.registry.set("ai", this._original as never);
|
|
80
|
+
} else {
|
|
81
|
+
this._app.container.registry.delete("ai");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Scripting ────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Queue one or more text answers, returned in order. The last one repeats
|
|
89
|
+
* once the queue is exhausted, so a test that does not care how many calls
|
|
90
|
+
* happen does not have to count them.
|
|
91
|
+
*/
|
|
92
|
+
respondWith(...texts: string[]): this {
|
|
93
|
+
this._texts.push(...texts);
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Queue one or more answers for `object()`, returned in order. */
|
|
98
|
+
respondWithObject(...objects: unknown[]): this {
|
|
99
|
+
this._objects.push(...objects);
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Make the next call refuse, as the provider's safety classifiers would.
|
|
105
|
+
*
|
|
106
|
+
* Worth testing deliberately: a refusal is an HTTP 200 with no content, so the
|
|
107
|
+
* handling path is the one most likely to have never run.
|
|
108
|
+
*/
|
|
109
|
+
refuse(category: string | null = "cyber", explanation: string | null = null): this {
|
|
110
|
+
this._refuseWith = { category, explanation };
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── The AiManager surface ────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async text(request: AiRequest | string): Promise<string> {
|
|
117
|
+
return (await this.generate(request)).text;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async generate(request: AiRequest | string): Promise<AiResponse> {
|
|
121
|
+
const normalized = capture(request);
|
|
122
|
+
this._record("text", normalized);
|
|
123
|
+
this._maybeRefuse();
|
|
124
|
+
|
|
125
|
+
const text = this._nextText();
|
|
126
|
+
return {
|
|
127
|
+
text,
|
|
128
|
+
model: "fake",
|
|
129
|
+
usage: NO_USAGE,
|
|
130
|
+
stopReason: "end_turn",
|
|
131
|
+
toolCalls: [],
|
|
132
|
+
assistantTurn: { role: "assistant", content: text },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async *stream(request: AiRequest | string): AsyncIterable<AiStreamChunk> {
|
|
137
|
+
const normalized = capture(request);
|
|
138
|
+
this._record("stream", normalized);
|
|
139
|
+
this._maybeRefuse();
|
|
140
|
+
|
|
141
|
+
const text = this._nextText();
|
|
142
|
+
// Word by word, because a caller that only ever sees one chunk is not
|
|
143
|
+
// actually exercising its accumulation.
|
|
144
|
+
for (const word of text.split(/(\s+)/)) {
|
|
145
|
+
if (word) yield { type: "text", text: word };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
yield {
|
|
149
|
+
type: "done",
|
|
150
|
+
response: {
|
|
151
|
+
text,
|
|
152
|
+
model: "fake",
|
|
153
|
+
usage: NO_USAGE,
|
|
154
|
+
stopReason: "end_turn",
|
|
155
|
+
toolCalls: [],
|
|
156
|
+
assistantTurn: { role: "assistant", content: text },
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async object<T = Record<string, unknown>>(request: AiRequest | string): Promise<T> {
|
|
162
|
+
const normalized = capture(request);
|
|
163
|
+
this._record("object", normalized);
|
|
164
|
+
this._maybeRefuse();
|
|
165
|
+
|
|
166
|
+
if (this._objects.length === 0) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
"[Zerotal/ai] AiFake had no scripted object to return. Call " +
|
|
169
|
+
"ai.respondWithObject({ … }) before the code under test runs.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return (this._objects.length > 1 ? this._objects.shift() : this._objects[0]) as T;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async agent(request: AiAgentRequest): Promise<AiAgentResult> {
|
|
176
|
+
this._record("agent", request);
|
|
177
|
+
this._maybeRefuse();
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
text: this._nextText(),
|
|
181
|
+
model: "fake",
|
|
182
|
+
usage: NO_USAGE,
|
|
183
|
+
steps: [],
|
|
184
|
+
stopReason: "end_turn",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async embed(
|
|
189
|
+
input: string | string[],
|
|
190
|
+
_options: Omit<AiEmbedRequest, "input"> = {},
|
|
191
|
+
): Promise<AiEmbedResponse> {
|
|
192
|
+
const inputs = Array.isArray(input) ? input : [input];
|
|
193
|
+
this._record("embed", { prompt: inputs.join("\n") });
|
|
194
|
+
|
|
195
|
+
// A deterministic, non-zero vector: a test asserting "these two differ"
|
|
196
|
+
// should pass, and one asserting a specific value should not.
|
|
197
|
+
return {
|
|
198
|
+
embeddings: inputs.map((value) => [value.length, value.charCodeAt(0) || 0, 0]),
|
|
199
|
+
model: "fake",
|
|
200
|
+
usage: { inputTokens: 0 },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async countTokens(request: AiRequest | string): Promise<number> {
|
|
205
|
+
const normalized = capture(request);
|
|
206
|
+
return Math.ceil(promptText(normalized).length / 4);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async verify(): Promise<DriverStatus> {
|
|
210
|
+
return { ok: true, model: "fake", detail: "AiFake is installed — no provider was contacted." };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
onGenerated(name: string, handler: AiQueueHandler): this {
|
|
214
|
+
this._handlers.set(name, handler);
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
handlerFor(name: string): AiQueueHandler | undefined {
|
|
219
|
+
return this._handlers.get(name);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Records the call and runs the handler inline — no worker, no queue driver. */
|
|
223
|
+
async queue(request: AiRequest, options: AiQueueOptions): Promise<void> {
|
|
224
|
+
this._record("queue", request);
|
|
225
|
+
const handler = this._handlers.get(options.handler);
|
|
226
|
+
if (handler) await handler(await this.generate(request), options.meta ?? {});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
drivers(): string[] {
|
|
230
|
+
return ["fake"];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Assertions ───────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** Every recorded call, in order. */
|
|
236
|
+
get calls(): CapturedGeneration[] {
|
|
237
|
+
return [...this._calls];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Just the prompts, in order. */
|
|
241
|
+
get prompts(): string[] {
|
|
242
|
+
return this._calls.map((call) => call.prompt);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Assert some prompt matched.
|
|
247
|
+
*
|
|
248
|
+
* @param expected - A substring, a regular expression, or a predicate.
|
|
249
|
+
*/
|
|
250
|
+
assertPrompted(expected: string | RegExp | ((prompt: string) => boolean)): void {
|
|
251
|
+
const matches = this._calls.some((call) => matchPrompt(call.prompt, expected));
|
|
252
|
+
if (matches) return;
|
|
253
|
+
|
|
254
|
+
throw new Error(
|
|
255
|
+
`[Zerotal/ai] Expected a prompt matching ${describe(expected)}, but ` +
|
|
256
|
+
(this._calls.length === 0
|
|
257
|
+
? "nothing was prompted."
|
|
258
|
+
: `the ${this._calls.length} prompt(s) were:\n` +
|
|
259
|
+
this.prompts.map((p) => ` - ${truncate(p)}`).join("\n")),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Assert no prompt matched. */
|
|
264
|
+
assertNotPrompted(expected: string | RegExp | ((prompt: string) => boolean)): void {
|
|
265
|
+
const match = this._calls.find((call) => matchPrompt(call.prompt, expected));
|
|
266
|
+
if (!match) return;
|
|
267
|
+
throw new Error(
|
|
268
|
+
`[Zerotal/ai] Expected no prompt matching ${describe(expected)}, but found: ${truncate(match.prompt)}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Assert the system prompt of some call matched. */
|
|
273
|
+
assertSystemPrompted(expected: string | RegExp | ((prompt: string) => boolean)): void {
|
|
274
|
+
const matches = this._calls.some(
|
|
275
|
+
(call) => call.system !== undefined && matchPrompt(call.system, expected),
|
|
276
|
+
);
|
|
277
|
+
if (matches) return;
|
|
278
|
+
throw new Error(
|
|
279
|
+
`[Zerotal/ai] Expected a system prompt matching ${describe(expected)}, but ` +
|
|
280
|
+
`none of the ${this._calls.length} call(s) had one that did.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Assert exactly `count` generations happened. */
|
|
285
|
+
assertPromptCount(count: number): void {
|
|
286
|
+
if (this._calls.length === count) return;
|
|
287
|
+
throw new Error(
|
|
288
|
+
`[Zerotal/ai] Expected ${count} generation(s), got ${this._calls.length}.` +
|
|
289
|
+
(this._calls.length > 0
|
|
290
|
+
? `\n${this._calls.map((c) => ` - ${c.operation}: ${truncate(c.prompt)}`).join("\n")}`
|
|
291
|
+
: ""),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Assert nothing was generated. */
|
|
296
|
+
assertNothingPrompted(): void {
|
|
297
|
+
this.assertPromptCount(0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Internals ────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
private _record(operation: string, request: Partial<AiRequest> & { prompt?: string }): void {
|
|
303
|
+
const full = request as AiRequest;
|
|
304
|
+
this._calls.push({
|
|
305
|
+
operation,
|
|
306
|
+
prompt: promptText(full) || (request.prompt ?? ""),
|
|
307
|
+
system: full.system,
|
|
308
|
+
request: full,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private _nextText(): string {
|
|
313
|
+
if (this._texts.length === 0) return DEFAULT_TEXT;
|
|
314
|
+
return this._texts.length > 1 ? this._texts.shift()! : this._texts[0]!;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private _maybeRefuse(): void {
|
|
318
|
+
if (!this._refuseWith) return;
|
|
319
|
+
const { category, explanation } = this._refuseWith;
|
|
320
|
+
this._refuseWith = undefined;
|
|
321
|
+
throw new AiRefusedError(category, explanation);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
function capture(request: AiRequest | string): AiRequest {
|
|
328
|
+
return typeof request === "string" ? { prompt: request } : request;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function matchPrompt(
|
|
332
|
+
prompt: string,
|
|
333
|
+
expected: string | RegExp | ((prompt: string) => boolean),
|
|
334
|
+
): boolean {
|
|
335
|
+
if (typeof expected === "function") return expected(prompt);
|
|
336
|
+
if (expected instanceof RegExp) return expected.test(prompt);
|
|
337
|
+
return prompt.includes(expected);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function describe(expected: string | RegExp | ((prompt: string) => boolean)): string {
|
|
341
|
+
if (typeof expected === "function") return "the given predicate";
|
|
342
|
+
if (expected instanceof RegExp) return String(expected);
|
|
343
|
+
return `"${expected}"`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function truncate(text: string, limit = 120): string {
|
|
347
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Re-exported so a fake's caller can normalise messages the same way. @internal */
|
|
351
|
+
export { normalizeMessages };
|