@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of `@anthropic-ai/sdk` this package uses, declared structurally.
|
|
3
|
+
*
|
|
4
|
+
* The SDK is an **optional** peer dependency: an app that only talks to Ollama
|
|
5
|
+
* should install nothing. That rules out importing its types, because a missing
|
|
6
|
+
* package is a `tsc` failure for every consumer, not just the ones using it. So
|
|
7
|
+
* the shape lives here instead, and the module is loaded through a non-literal
|
|
8
|
+
* specifier so the compiler never tries to resolve it.
|
|
9
|
+
*
|
|
10
|
+
* The cost is that this file, alone in the package, is only as correct as the
|
|
11
|
+
* SDK's documented wire format. Everything it touches is exercised by
|
|
12
|
+
* `AnthropicDriver.test.ts` against an injected stub built to these same types —
|
|
13
|
+
* which pins our *use* of the shapes, not the shapes themselves. Keep it small.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A content block in a response. Unknown block types keep their raw fields. */
|
|
17
|
+
export type AnthropicBlock =
|
|
18
|
+
| { type: "text"; text: string }
|
|
19
|
+
| { type: "thinking"; thinking: string }
|
|
20
|
+
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
|
|
21
|
+
| { type: string; [key: string]: unknown };
|
|
22
|
+
|
|
23
|
+
/** Token accounting, as the API reports it. */
|
|
24
|
+
export interface AnthropicUsage {
|
|
25
|
+
input_tokens: number;
|
|
26
|
+
output_tokens: number;
|
|
27
|
+
cache_read_input_tokens?: number | null;
|
|
28
|
+
cache_creation_input_tokens?: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Populated only when `stop_reason` is `refusal`; `null` otherwise. */
|
|
32
|
+
export interface AnthropicStopDetails {
|
|
33
|
+
type?: string;
|
|
34
|
+
category?: string | null;
|
|
35
|
+
explanation?: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One finished message. */
|
|
39
|
+
export interface AnthropicMessage {
|
|
40
|
+
id?: string;
|
|
41
|
+
model: string;
|
|
42
|
+
content: AnthropicBlock[];
|
|
43
|
+
stop_reason: string | null;
|
|
44
|
+
stop_details?: AnthropicStopDetails | null;
|
|
45
|
+
usage: AnthropicUsage;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The stream events this package reads. Others are ignored. */
|
|
49
|
+
export type AnthropicStreamEvent =
|
|
50
|
+
| {
|
|
51
|
+
type: "content_block_delta";
|
|
52
|
+
delta:
|
|
53
|
+
| { type: "text_delta"; text: string }
|
|
54
|
+
| { type: "thinking_delta"; thinking: string }
|
|
55
|
+
| { type: string; [key: string]: unknown };
|
|
56
|
+
}
|
|
57
|
+
| { type: string; [key: string]: unknown };
|
|
58
|
+
|
|
59
|
+
/** What `messages.stream()` returns. */
|
|
60
|
+
export interface AnthropicMessageStream extends AsyncIterable<AnthropicStreamEvent> {
|
|
61
|
+
finalMessage(): Promise<AnthropicMessage>;
|
|
62
|
+
abort?(): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Per-request options. Note the SDK's timeout is in **milliseconds**. */
|
|
66
|
+
export interface AnthropicRequestOptions {
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
timeout?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One `messages` namespace — the shape is identical on `client` and `client.beta`. */
|
|
72
|
+
export interface AnthropicMessagesApi {
|
|
73
|
+
create(
|
|
74
|
+
params: Record<string, unknown>,
|
|
75
|
+
options?: AnthropicRequestOptions,
|
|
76
|
+
): Promise<AnthropicMessage>;
|
|
77
|
+
stream(
|
|
78
|
+
params: Record<string, unknown>,
|
|
79
|
+
options?: AnthropicRequestOptions,
|
|
80
|
+
): AnthropicMessageStream;
|
|
81
|
+
countTokens?(
|
|
82
|
+
params: Record<string, unknown>,
|
|
83
|
+
options?: AnthropicRequestOptions,
|
|
84
|
+
): Promise<{ input_tokens: number }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The client surface this driver drives. */
|
|
88
|
+
export interface AnthropicClient {
|
|
89
|
+
messages: AnthropicMessagesApi;
|
|
90
|
+
beta: { messages: AnthropicMessagesApi };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Anything `instanceof` can be tested against. */
|
|
94
|
+
export type ErrorClass = new (...args: never[]) => Error;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The SDK's default export: a constructor that also carries the typed error
|
|
98
|
+
* classes. Mapping errors **by class** rather than by matching message text is
|
|
99
|
+
* the difference between a rate limit staying a rate limit and it becoming
|
|
100
|
+
* whatever the provider decides to call it next quarter.
|
|
101
|
+
*/
|
|
102
|
+
export interface AnthropicConstructor {
|
|
103
|
+
new (options: {
|
|
104
|
+
apiKey: string;
|
|
105
|
+
baseURL?: string;
|
|
106
|
+
timeout?: number;
|
|
107
|
+
maxRetries?: number;
|
|
108
|
+
}): AnthropicClient;
|
|
109
|
+
RateLimitError: ErrorClass;
|
|
110
|
+
APIConnectionError: ErrorClass;
|
|
111
|
+
AuthenticationError: ErrorClass;
|
|
112
|
+
NotFoundError: ErrorClass;
|
|
113
|
+
BadRequestError: ErrorClass;
|
|
114
|
+
APIError: ErrorClass;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The module namespace. */
|
|
118
|
+
export interface AnthropicModule {
|
|
119
|
+
default: AnthropicConstructor;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Both halves of a loaded SDK: the class (for `instanceof`) and a client. */
|
|
123
|
+
export interface LoadedAnthropic {
|
|
124
|
+
ctor: AnthropicConstructor;
|
|
125
|
+
client: AnthropicClient;
|
|
126
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AiEmbedRequest, AiEmbedResponse } from "../../types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What an embeddings provider implements.
|
|
5
|
+
*
|
|
6
|
+
* Separate from {@link AiDriver} on purpose, and separately configured: Anthropic
|
|
7
|
+
* has no embeddings endpoint at all, so a design that hangs `embed()` off the
|
|
8
|
+
* generation driver makes the normal pairing — Claude for generation, something
|
|
9
|
+
* cheaper for vectors — impossible to express.
|
|
10
|
+
*/
|
|
11
|
+
export interface EmbeddingsDriver {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly model: string;
|
|
14
|
+
embed(request: AiEmbedRequest): Promise<AiEmbedResponse>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Normalise `string | string[]` to the list every provider actually wants. */
|
|
18
|
+
export function toInputs(input: string | string[]): string[] {
|
|
19
|
+
return Array.isArray(input) ? input : [input];
|
|
20
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { AiCancelledError, AiRequestError } from "../../errors.ts";
|
|
2
|
+
import type { AiEmbedRequest, AiEmbedResponse } from "../../types.ts";
|
|
3
|
+
import { toInputs, type EmbeddingsDriver } from "./EmbeddingsDriver.ts";
|
|
4
|
+
|
|
5
|
+
interface OllamaEmbedResponse {
|
|
6
|
+
model?: string;
|
|
7
|
+
embeddings: number[][];
|
|
8
|
+
prompt_eval_count?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Ollama embeddings — a local server, so no key and no per-token cost. */
|
|
12
|
+
export class OllamaEmbeddingsDriver implements EmbeddingsDriver {
|
|
13
|
+
readonly name = "ollama";
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly config: { model: string; baseUrl: string; timeout: number },
|
|
17
|
+
private readonly fetchImpl: typeof fetch = fetch,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
get model(): string {
|
|
21
|
+
return this.config.model;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async embed(request: AiEmbedRequest): Promise<AiEmbedResponse> {
|
|
25
|
+
const model = request.model ?? this.config.model;
|
|
26
|
+
const inputs = toInputs(request.input);
|
|
27
|
+
|
|
28
|
+
let response: Response;
|
|
29
|
+
try {
|
|
30
|
+
response = await this.fetchImpl(`${this.config.baseUrl}/api/embed`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ model, input: inputs }),
|
|
34
|
+
signal: request.signal ?? AbortSignal.timeout(this.config.timeout),
|
|
35
|
+
});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (request.signal?.aborted) throw new AiCancelledError();
|
|
38
|
+
throw new AiRequestError(
|
|
39
|
+
`Could not reach Ollama at ${this.config.baseUrl}: ` +
|
|
40
|
+
`${error instanceof Error ? error.message : String(error)}. Is \`ollama serve\` running?`,
|
|
41
|
+
0,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const detail = await response.text().catch(() => "");
|
|
47
|
+
throw new AiRequestError(
|
|
48
|
+
`Ollama embeddings error ${response.status}: ${detail || response.statusText}`,
|
|
49
|
+
response.status,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const body = (await response.json()) as OllamaEmbedResponse;
|
|
54
|
+
return {
|
|
55
|
+
embeddings: body.embeddings,
|
|
56
|
+
model: body.model ?? model,
|
|
57
|
+
usage: { inputTokens: body.prompt_eval_count ?? 0 },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { AiCancelledError, AiRateLimitError, AiRequestError } from "../../errors.ts";
|
|
2
|
+
import type { AiEmbedRequest, AiEmbedResponse } from "../../types.ts";
|
|
3
|
+
import { toInputs, type EmbeddingsDriver } from "./EmbeddingsDriver.ts";
|
|
4
|
+
|
|
5
|
+
interface OpenAiEmbeddingResponse {
|
|
6
|
+
model: string;
|
|
7
|
+
data: Array<{ index: number; embedding: number[] }>;
|
|
8
|
+
usage?: { prompt_tokens?: number };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** OpenAI embeddings over `fetch`. No SDK, no dependency. */
|
|
12
|
+
export class OpenAiEmbeddingsDriver implements EmbeddingsDriver {
|
|
13
|
+
readonly name = "openai";
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly config: { apiKey: string; model: string; baseUrl: string; timeout: number },
|
|
17
|
+
private readonly fetchImpl: typeof fetch = fetch,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
get model(): string {
|
|
21
|
+
return this.config.model;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async embed(request: AiEmbedRequest): Promise<AiEmbedResponse> {
|
|
25
|
+
const inputs = toInputs(request.input);
|
|
26
|
+
|
|
27
|
+
let response: Response;
|
|
28
|
+
try {
|
|
29
|
+
response = await this.fetchImpl(`${this.config.baseUrl}/embeddings`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({ model: request.model ?? this.config.model, input: inputs }),
|
|
36
|
+
signal: request.signal ?? AbortSignal.timeout(this.config.timeout),
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (request.signal?.aborted) throw new AiCancelledError();
|
|
40
|
+
throw new AiRequestError(
|
|
41
|
+
`Could not reach the OpenAI API: ${error instanceof Error ? error.message : String(error)}`,
|
|
42
|
+
0,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const detail = await response.text().catch(() => "");
|
|
48
|
+
if (response.status === 429) {
|
|
49
|
+
throw new AiRateLimitError(`OpenAI rate limit: ${detail || response.statusText}`);
|
|
50
|
+
}
|
|
51
|
+
throw new AiRequestError(
|
|
52
|
+
`OpenAI embeddings error ${response.status}: ${detail || response.statusText}`,
|
|
53
|
+
response.status,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const body = (await response.json()) as OpenAiEmbeddingResponse;
|
|
58
|
+
|
|
59
|
+
// The API does not promise input order, and a mis-ordered batch silently
|
|
60
|
+
// attaches every vector to the wrong document.
|
|
61
|
+
const ordered = [...body.data].sort((a, b) => a.index - b.index);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
embeddings: ordered.map((entry) => entry.embedding),
|
|
65
|
+
model: body.model,
|
|
66
|
+
usage: { inputTokens: body.usage?.prompt_tokens ?? 0 },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { AiCancelledError, AiRequestError } from "../errors.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a `text/event-stream` body, yielding each event's `data:` payload.
|
|
5
|
+
*
|
|
6
|
+
* Frames do not align with chunks — a single `read()` can hand back half an
|
|
7
|
+
* event, or three of them — so the buffer is split on the blank-line terminator
|
|
8
|
+
* rather than per chunk. Getting that wrong produces a stream that works on a
|
|
9
|
+
* fast localhost and drops tokens over a real network.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export async function* readSse(response: Response, signal?: AbortSignal): AsyncGenerator<string> {
|
|
14
|
+
const body = response.body;
|
|
15
|
+
if (!body)
|
|
16
|
+
throw new AiRequestError("The provider returned a streaming response with no body.", 0);
|
|
17
|
+
|
|
18
|
+
const reader = body.getReader();
|
|
19
|
+
const decoder = new TextDecoder();
|
|
20
|
+
let buffer = "";
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
for (;;) {
|
|
24
|
+
if (signal?.aborted) throw new AiCancelledError();
|
|
25
|
+
|
|
26
|
+
const { done, value } = await reader.read();
|
|
27
|
+
if (done) break;
|
|
28
|
+
|
|
29
|
+
buffer += decoder.decode(value, { stream: true });
|
|
30
|
+
|
|
31
|
+
let boundary = buffer.indexOf("\n\n");
|
|
32
|
+
while (boundary !== -1) {
|
|
33
|
+
const frame = buffer.slice(0, boundary);
|
|
34
|
+
buffer = buffer.slice(boundary + 2);
|
|
35
|
+
|
|
36
|
+
const data = dataOf(frame);
|
|
37
|
+
if (data !== undefined) yield data;
|
|
38
|
+
|
|
39
|
+
boundary = buffer.indexOf("\n\n");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A final frame with no trailing blank line still carries an event.
|
|
44
|
+
const tail = dataOf(buffer);
|
|
45
|
+
if (tail !== undefined) yield tail;
|
|
46
|
+
} finally {
|
|
47
|
+
// Releasing matters on the abort path: the response body stays open until
|
|
48
|
+
// the reader lets go, and an abandoned stream holds the socket.
|
|
49
|
+
reader.releaseLock();
|
|
50
|
+
if (!response.bodyUsed) await body.cancel().catch(() => undefined);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The concatenated `data:` lines of one frame, or `undefined` for a comment. */
|
|
55
|
+
function dataOf(frame: string): string | undefined {
|
|
56
|
+
const lines = frame.split("\n");
|
|
57
|
+
const data: string[] = [];
|
|
58
|
+
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data.length > 0 ? data.join("\n") : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read a newline-delimited JSON body, yielding each line. Ollama's stream format.
|
|
68
|
+
*
|
|
69
|
+
* @internal
|
|
70
|
+
*/
|
|
71
|
+
export async function* readNdjson(
|
|
72
|
+
response: Response,
|
|
73
|
+
signal?: AbortSignal,
|
|
74
|
+
): AsyncGenerator<string> {
|
|
75
|
+
const body = response.body;
|
|
76
|
+
if (!body)
|
|
77
|
+
throw new AiRequestError("The provider returned a streaming response with no body.", 0);
|
|
78
|
+
|
|
79
|
+
const reader = body.getReader();
|
|
80
|
+
const decoder = new TextDecoder();
|
|
81
|
+
let buffer = "";
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (signal?.aborted) throw new AiCancelledError();
|
|
86
|
+
|
|
87
|
+
const { done, value } = await reader.read();
|
|
88
|
+
if (done) break;
|
|
89
|
+
|
|
90
|
+
buffer += decoder.decode(value, { stream: true });
|
|
91
|
+
|
|
92
|
+
let newline = buffer.indexOf("\n");
|
|
93
|
+
while (newline !== -1) {
|
|
94
|
+
const line = buffer.slice(0, newline).trim();
|
|
95
|
+
buffer = buffer.slice(newline + 1);
|
|
96
|
+
if (line) yield line;
|
|
97
|
+
newline = buffer.indexOf("\n");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tail = buffer.trim();
|
|
102
|
+
if (tail) yield tail;
|
|
103
|
+
} finally {
|
|
104
|
+
reader.releaseLock();
|
|
105
|
+
if (!response.bodyUsed) await body.cancel().catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Base class for all `@zerotal/ai` errors. */
|
|
4
|
+
export class AiError extends ZerotalError {
|
|
5
|
+
constructor(message: string, code = "E_AI", status = 500, context?: Record<string, unknown>) {
|
|
6
|
+
super(message, code, status, context);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Thrown for a driver name the manager does not know. */
|
|
11
|
+
export class UnknownAiDriverError extends AiError {
|
|
12
|
+
constructor(driver: string, known: string[] = []) {
|
|
13
|
+
super(
|
|
14
|
+
`Unknown AI driver: '${driver}'.` +
|
|
15
|
+
(known.length > 0
|
|
16
|
+
? ` Configured: ${known.join(", ")}. Add your own with Ai.extend("${driver}", () => …).`
|
|
17
|
+
: " Configure one under drivers in config/ai.ts."),
|
|
18
|
+
"E_AI_UNKNOWN_DRIVER",
|
|
19
|
+
500,
|
|
20
|
+
{ driver, known },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Thrown at boot, or on first use, for a config combination that cannot work. */
|
|
26
|
+
export class AiConfigError extends AiError {
|
|
27
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
28
|
+
super(`[Zerotal/ai] ${message}`, "E_AI_CONFIG", 500, context);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Thrown when a driver's optional peer package is not installed.
|
|
34
|
+
*
|
|
35
|
+
* The SDKs are optional peers so an app that only talks to Ollama installs
|
|
36
|
+
* nothing; the cost is that the failure has to name the missing package.
|
|
37
|
+
*/
|
|
38
|
+
export class AiDriverUnavailableError extends AiError {
|
|
39
|
+
constructor(driver: string, packageName: string) {
|
|
40
|
+
super(
|
|
41
|
+
`[Zerotal/ai] The '${driver}' driver needs ${packageName}, which is not installed. ` +
|
|
42
|
+
`Run: bun add ${packageName}`,
|
|
43
|
+
"E_AI_DRIVER_UNAVAILABLE",
|
|
44
|
+
500,
|
|
45
|
+
{ driver, packageName },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Thrown when the provider's safety classifiers declined the request.
|
|
52
|
+
*
|
|
53
|
+
* This arrives as a **successful** HTTP 200 with an empty or partial body, so a
|
|
54
|
+
* caller that reads `content[0]` without checking the stop reason crashes on a
|
|
55
|
+
* response the API considers fine. Raising a typed error is the whole point.
|
|
56
|
+
*/
|
|
57
|
+
export class AiRefusedError extends AiError {
|
|
58
|
+
constructor(
|
|
59
|
+
/** The provider's refusal category, when it gave one — `cyber`, `bio`, … */
|
|
60
|
+
readonly category: string | null,
|
|
61
|
+
/** The provider's own explanation, when it gave one. */
|
|
62
|
+
readonly explanation: string | null,
|
|
63
|
+
/** Whatever text arrived before the refusal, for a mid-stream decline. */
|
|
64
|
+
readonly partialText = "",
|
|
65
|
+
) {
|
|
66
|
+
super(
|
|
67
|
+
`[Zerotal/ai] The model declined the request` +
|
|
68
|
+
(category ? ` (${category})` : "") +
|
|
69
|
+
(explanation ? `: ${explanation}` : "."),
|
|
70
|
+
"E_AI_REFUSED",
|
|
71
|
+
// Not a server fault and not the caller's malformed input — it is a
|
|
72
|
+
// policy decision about the content, which is what 422 says.
|
|
73
|
+
422,
|
|
74
|
+
{ category, explanation },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Thrown when the provider rate-limits. The SDKs already retried. */
|
|
80
|
+
export class AiRateLimitError extends AiError {
|
|
81
|
+
constructor(
|
|
82
|
+
message: string,
|
|
83
|
+
readonly retryAfterSeconds?: number,
|
|
84
|
+
) {
|
|
85
|
+
super(`[Zerotal/ai] ${message}`, "E_AI_RATE_LIMIT", 429, { retryAfterSeconds });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Thrown for any other non-2xx from the provider, carrying its status. */
|
|
90
|
+
export class AiRequestError extends AiError {
|
|
91
|
+
constructor(
|
|
92
|
+
message: string,
|
|
93
|
+
readonly providerStatus: number,
|
|
94
|
+
context?: Record<string, unknown>,
|
|
95
|
+
) {
|
|
96
|
+
super(`[Zerotal/ai] ${message}`, "E_AI_REQUEST", providerStatus >= 500 ? 502 : 400, {
|
|
97
|
+
providerStatus,
|
|
98
|
+
...context,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Thrown when a request would breach a configured spend ceiling. */
|
|
104
|
+
export class AiSpendLimitError extends AiError {
|
|
105
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
106
|
+
super(`[Zerotal/ai] ${message}`, "E_AI_SPEND_LIMIT", 429, context);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Thrown when a validator schema uses a constraint structured output cannot express. */
|
|
111
|
+
export class AiSchemaError extends AiError {
|
|
112
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
113
|
+
super(`[Zerotal/ai] ${message}`, "E_AI_SCHEMA", 500, context);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Thrown when the agent loop hits its step or resume ceiling. */
|
|
118
|
+
export class AiAgentLimitError extends AiError {
|
|
119
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
120
|
+
super(`[Zerotal/ai] ${message}`, "E_AI_AGENT_LIMIT", 500, context);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Thrown when the caller's `AbortSignal` fired before the call finished. */
|
|
125
|
+
export class AiCancelledError extends AiError {
|
|
126
|
+
constructor(message = "The generation was cancelled.") {
|
|
127
|
+
super(`[Zerotal/ai] ${message}`, "E_AI_CANCELLED", 499);
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI package's framework events, emitted on core's `FrameworkEvents` bus.
|
|
3
|
+
* Observability packages subscribe to them by class; nothing here imports them.
|
|
4
|
+
*
|
|
5
|
+
* Prompt text on these events has already been through {@link redactPrompt} —
|
|
6
|
+
* a prompt is user data, and the observability path is the one place it would
|
|
7
|
+
* otherwise be durably kept.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Emitted after a generation finishes, whether it succeeded or not. */
|
|
11
|
+
export class AiGenerated {
|
|
12
|
+
constructor(
|
|
13
|
+
/** The driver that served it: "anthropic" | "openai" | "ollama" | custom. */
|
|
14
|
+
readonly driver: string,
|
|
15
|
+
readonly model: string,
|
|
16
|
+
/** "text" | "stream" | "object" | "agent" | "embed". */
|
|
17
|
+
readonly operation: string,
|
|
18
|
+
readonly inputTokens: number,
|
|
19
|
+
readonly outputTokens: number,
|
|
20
|
+
readonly cacheReadTokens: number,
|
|
21
|
+
readonly durationMs: number,
|
|
22
|
+
/** Estimated cost in USD, from the driver's price table. 0 when unpriced. */
|
|
23
|
+
readonly costUsd: number,
|
|
24
|
+
readonly ok: boolean,
|
|
25
|
+
/** Redacted prompt preview — never the raw prompt. */
|
|
26
|
+
readonly preview: string,
|
|
27
|
+
readonly error?: string,
|
|
28
|
+
) {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Emitted when the provider's safety classifiers declined a request. */
|
|
32
|
+
export class AiRefused {
|
|
33
|
+
constructor(
|
|
34
|
+
readonly driver: string,
|
|
35
|
+
readonly model: string,
|
|
36
|
+
readonly category: string | null,
|
|
37
|
+
readonly preview: string,
|
|
38
|
+
) {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Emitted once per tool call inside an agent run. */
|
|
42
|
+
export class AiToolCalled {
|
|
43
|
+
constructor(
|
|
44
|
+
readonly driver: string,
|
|
45
|
+
readonly tool: string,
|
|
46
|
+
readonly step: number,
|
|
47
|
+
readonly durationMs: number,
|
|
48
|
+
readonly ok: boolean,
|
|
49
|
+
readonly error?: string,
|
|
50
|
+
) {}
|
|
51
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Ambient declarations specific to this package.
|
|
2
|
+
// Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
|
|
3
|
+
// Only declarations bun-types does NOT provide are kept here.
|
|
4
|
+
|
|
5
|
+
// ── Bun globals ───────────────────────────────────────────────────────────
|
|
6
|
+
interface Request {
|
|
7
|
+
readonly params?: Record<string, string>;
|
|
8
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic AI generation for Zerotal.
|
|
3
|
+
*
|
|
4
|
+
* Text, streaming, structured output, typed tools, and an agent loop, behind one
|
|
5
|
+
* facade — with the provider chosen in config rather than in every call site.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { Ai, tool } from "@zerotal/ai";
|
|
10
|
+
*
|
|
11
|
+
* const summary = await Ai.text(`Summarize in one sentence:\n\n${article}`);
|
|
12
|
+
*
|
|
13
|
+
* for await (const chunk of Ai.stream({ prompt, signal: this.signal })) {
|
|
14
|
+
* if (chunk.type === "text") this.answer += chunk.text;
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* const review = await Ai.object({ prompt }, (rule) => ({
|
|
18
|
+
* sentiment: rule.string().in(["positive", "neutral", "negative"]),
|
|
19
|
+
* score: rule.number().min(1).max(5),
|
|
20
|
+
* }));
|
|
21
|
+
*
|
|
22
|
+
* const result = await Ai.agent({ prompt, tools: [lookupOrder], lock: "order:4821" });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @packageDocumentation
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export { AiManager } from "./AiManager.ts";
|
|
29
|
+
export type { AiAgentRequest, AiQueueHandler, AiQueueOptions } from "./AiManager.ts";
|
|
30
|
+
export { Ai } from "./facades/Ai.ts";
|
|
31
|
+
export { AiProvider } from "./provider/AiProvider.ts";
|
|
32
|
+
export { AiConfig, AiConfigFromEnv, validateAiConfig } from "./config.ts";
|
|
33
|
+
export type { AiConfigInput } from "./config.ts";
|
|
34
|
+
export { AiFake } from "./AiFake.ts";
|
|
35
|
+
export type { CapturedGeneration } from "./AiFake.ts";
|
|
36
|
+
|
|
37
|
+
// Tools
|
|
38
|
+
export { tool } from "./tool.ts";
|
|
39
|
+
|
|
40
|
+
// Schema translation — exported so a test can pin the exact JSON Schema a
|
|
41
|
+
// validator schema produces, which is the only way that stays honest.
|
|
42
|
+
export { translateSchema, strippedConstraints, toSchema } from "./schema.ts";
|
|
43
|
+
export type { SchemaInput } from "./schema.ts";
|
|
44
|
+
|
|
45
|
+
// Drivers — implement the interface to add a provider, or instantiate directly.
|
|
46
|
+
export { AnthropicDriver } from "./drivers/AnthropicDriver.ts";
|
|
47
|
+
export { OpenAiDriver } from "./drivers/OpenAiDriver.ts";
|
|
48
|
+
export { OllamaDriver } from "./drivers/OllamaDriver.ts";
|
|
49
|
+
export type { AiDriver, AgentOptions, DriverStatus } from "./drivers/AiDriver.ts";
|
|
50
|
+
|
|
51
|
+
// Embeddings — their own driver, because Anthropic has no embeddings endpoint.
|
|
52
|
+
export { OpenAiEmbeddingsDriver } from "./drivers/embeddings/OpenAiEmbeddingsDriver.ts";
|
|
53
|
+
export { OllamaEmbeddingsDriver } from "./drivers/embeddings/OllamaEmbeddingsDriver.ts";
|
|
54
|
+
export type { EmbeddingsDriver } from "./drivers/embeddings/EmbeddingsDriver.ts";
|
|
55
|
+
|
|
56
|
+
// Background generation
|
|
57
|
+
export { AiGenerationJob } from "./AiGenerationJob.ts";
|
|
58
|
+
|
|
59
|
+
// Cost estimation — extend the table for a model this package does not price.
|
|
60
|
+
export { estimateCost, modelPrice, registerModelPrice, modelRejectsSampling } from "./pricing.ts";
|
|
61
|
+
export type { ModelPrice } from "./pricing.ts";
|
|
62
|
+
|
|
63
|
+
// Spend ledger
|
|
64
|
+
export { spentToday, resetSpend } from "./spend.ts";
|
|
65
|
+
|
|
66
|
+
// Counters backing the monitor section
|
|
67
|
+
export { modelStats, recentGenerations, refusalRate, resetStats } from "./stats.ts";
|
|
68
|
+
export type { AiDelivery, ModelStat } from "./stats.ts";
|
|
69
|
+
|
|
70
|
+
// Console commands
|
|
71
|
+
export { AiTestCommand, AiSpendCommand } from "./commands/index.ts";
|
|
72
|
+
|
|
73
|
+
// Typed error vocabulary
|
|
74
|
+
export * from "./errors.ts";
|
|
75
|
+
|
|
76
|
+
// Framework instrumentation events (emitted on the core FrameworkEvents bus)
|
|
77
|
+
export { AiGenerated, AiRefused, AiToolCalled } from "./events.ts";
|
|
78
|
+
|
|
79
|
+
export type {
|
|
80
|
+
AiAgentResult,
|
|
81
|
+
AiAgentStep,
|
|
82
|
+
AiConfigShape,
|
|
83
|
+
AiEffort,
|
|
84
|
+
AiEmbedRequest,
|
|
85
|
+
AiEmbedResponse,
|
|
86
|
+
AiLimitsConfigShape,
|
|
87
|
+
AiAgentConfigShape,
|
|
88
|
+
AiMessage,
|
|
89
|
+
AiObjectResponse,
|
|
90
|
+
AiProviderOptions,
|
|
91
|
+
AiRequest,
|
|
92
|
+
AiResponse,
|
|
93
|
+
AiRole,
|
|
94
|
+
AiStopReason,
|
|
95
|
+
AiStreamChunk,
|
|
96
|
+
AiTool,
|
|
97
|
+
AiToolCall,
|
|
98
|
+
AiToolContext,
|
|
99
|
+
AiToolResult,
|
|
100
|
+
AiUsage,
|
|
101
|
+
AnthropicConfigShape,
|
|
102
|
+
EmbeddingsConfigShape,
|
|
103
|
+
JsonSchema,
|
|
104
|
+
OllamaConfigShape,
|
|
105
|
+
OpenAiConfigShape,
|
|
106
|
+
} from "./types.ts";
|