@nestjs/ai 0.0.0-stage → 0.0.1
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/LICENSE +23 -0
- package/README.md +108 -2
- package/dist/ai.constants.d.ts +22 -0
- package/dist/ai.constants.js +24 -0
- package/dist/ai.module-definition.d.ts +23 -0
- package/dist/ai.module-definition.js +30 -0
- package/dist/ai.module.d.ts +11 -0
- package/dist/ai.module.js +36 -0
- package/dist/context/ai-execution-context.d.ts +32 -0
- package/dist/context/ai-execution-context.js +41 -0
- package/dist/context/ai-params.d.ts +20 -0
- package/dist/context/ai-params.js +44 -0
- package/dist/decorators/agent-function.decorator.d.ts +22 -0
- package/dist/decorators/agent-function.decorator.js +36 -0
- package/dist/decorators/index.d.ts +2 -0
- package/dist/decorators/index.js +2 -0
- package/dist/decorators/inject-model.decorator.d.ts +10 -0
- package/dist/decorators/inject-model.decorator.js +16 -0
- package/dist/errors/ai.errors.d.ts +54 -0
- package/dist/errors/ai.errors.js +62 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +1 -0
- package/dist/http/keep-alive.util.d.ts +6 -0
- package/dist/http/keep-alive.util.js +38 -0
- package/dist/http/node-response.util.d.ts +14 -0
- package/dist/http/node-response.util.js +104 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +15 -0
- package/dist/interfaces/agent-function.interface.d.ts +53 -0
- package/dist/interfaces/agent-function.interface.js +1 -0
- package/dist/interfaces/ai-call-options.interface.d.ts +65 -0
- package/dist/interfaces/ai-call-options.interface.js +1 -0
- package/dist/interfaces/ai-module-options.interface.d.ts +88 -0
- package/dist/interfaces/ai-module-options.interface.js +1 -0
- package/dist/interfaces/ai-result.interface.d.ts +54 -0
- package/dist/interfaces/ai-result.interface.js +1 -0
- package/dist/interfaces/ai-schema.interface.d.ts +44 -0
- package/dist/interfaces/ai-schema.interface.js +1 -0
- package/dist/interfaces/ai-tool.interface.d.ts +22 -0
- package/dist/interfaces/ai-tool.interface.js +1 -0
- package/dist/interfaces/ai-usage.interface.d.ts +30 -0
- package/dist/interfaces/ai-usage.interface.js +1 -0
- package/dist/interfaces/index.d.ts +7 -0
- package/dist/interfaces/index.js +7 -0
- package/dist/models/ai-models.service.d.ts +29 -0
- package/dist/models/ai-models.service.js +113 -0
- package/dist/models/model-requests.d.ts +2 -0
- package/dist/models/model-requests.js +12 -0
- package/dist/runtime/agent-loop.d.ts +30 -0
- package/dist/runtime/agent-loop.js +81 -0
- package/dist/runtime/call-plan.d.ts +54 -0
- package/dist/runtime/call-plan.js +105 -0
- package/dist/runtime/model-policy.d.ts +48 -0
- package/dist/runtime/model-policy.js +202 -0
- package/dist/runtime/steps.d.ts +32 -0
- package/dist/runtime/steps.js +190 -0
- package/dist/runtime/text-stream.d.ts +64 -0
- package/dist/runtime/text-stream.js +211 -0
- package/dist/services/ai.service.d.ts +57 -0
- package/dist/services/ai.service.js +139 -0
- package/dist/testing/fake-model.d.ts +96 -0
- package/dist/testing/fake-model.js +201 -0
- package/dist/testing/index.d.ts +26 -0
- package/dist/testing/index.js +19 -0
- package/dist/tools/ai-tools.service.d.ts +77 -0
- package/dist/tools/ai-tools.service.js +234 -0
- package/dist/tools/tool-output.util.d.ts +6 -0
- package/dist/tools/tool-output.util.js +38 -0
- package/dist/utils/duration.util.d.ts +3 -0
- package/dist/utils/duration.util.js +17 -0
- package/dist/utils/errors.util.d.ts +16 -0
- package/dist/utils/errors.util.js +35 -0
- package/dist/utils/names.util.d.ts +2 -0
- package/dist/utils/names.util.js +10 -0
- package/dist/utils/schema.util.d.ts +16 -0
- package/dist/utils/schema.util.js +70 -0
- package/dist/utils/usage.util.d.ts +10 -0
- package/dist/utils/usage.util.js +42 -0
- package/dist/workflows/ai-agent.interfaces.d.ts +70 -0
- package/dist/workflows/ai-agent.interfaces.js +1 -0
- package/dist/workflows/ai-agent.workflow.d.ts +15 -0
- package/dist/workflows/ai-agent.workflow.js +25 -0
- package/dist/workflows/ai-agents.service.d.ts +83 -0
- package/dist/workflows/ai-agents.service.js +277 -0
- package/dist/workflows/ai-workflows.module.d.ts +6 -0
- package/dist/workflows/ai-workflows.module.js +22 -0
- package/dist/workflows/index.d.ts +6 -0
- package/dist/workflows/index.js +5 -0
- package/package.json +100 -4
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
14
|
+
import { ModuleRef } from '@nestjs/core';
|
|
15
|
+
import { ResilienceService } from '@nestjs/resilience';
|
|
16
|
+
import { AI_MODULE_OPTIONS } from '../ai.constants.js';
|
|
17
|
+
import { AiConfigurationError, AiOutputError } from '../errors/ai.errors.js';
|
|
18
|
+
import { AiModels } from '../models/ai-models.service.js';
|
|
19
|
+
import { runLoop, usageEvent } from '../runtime/agent-loop.js';
|
|
20
|
+
import { createCallPlan } from '../runtime/call-plan.js';
|
|
21
|
+
import { runStep } from '../runtime/model-policy.js';
|
|
22
|
+
import { generateStep } from '../runtime/steps.js';
|
|
23
|
+
import { AiTextStream } from '../runtime/text-stream.js';
|
|
24
|
+
import { AiTools } from '../tools/ai-tools.service.js';
|
|
25
|
+
import { toModelSchema } from '../utils/schema.util.js';
|
|
26
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
27
|
+
/**
|
|
28
|
+
* Model calls: text, structured output and streams, with the application's agent functions as tools, through the
|
|
29
|
+
* configured models, with timeouts, retries, fallback, budgets and usage events.
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* const { text } = await this.ai.generateText({ prompt: 'Summarize this ticket: ...' });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
let AiService = class AiService {
|
|
36
|
+
constructor(models, tools, options, moduleRef) {
|
|
37
|
+
this.models = models;
|
|
38
|
+
this.tools = tools;
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.moduleRef = moduleRef;
|
|
41
|
+
this.logger = new Logger('AiModule');
|
|
42
|
+
}
|
|
43
|
+
/** Generates text; with `tools`, calls them as the model asks, and answers when it is done. */
|
|
44
|
+
async generateText(options) {
|
|
45
|
+
const plan = this.plan(options, options?.abortSignal ?? NEVER_ABORTED);
|
|
46
|
+
const { result } = await runLoop(plan, { stream: false }, (event) => this.report(plan, event));
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Generates an object that matches `schema`: an invoice from an email, a triage decision from a ticket. Rejects
|
|
51
|
+
* with `AiOutputError` when the answer does not match it.
|
|
52
|
+
*/
|
|
53
|
+
async generateObject(options) {
|
|
54
|
+
const schema = toModelSchema(options?.schema, 'generateObject() `schema`');
|
|
55
|
+
const plan = this.plan(options, options.abortSignal ?? NEVER_ABORTED);
|
|
56
|
+
const { result, output } = await runLoop(plan, {
|
|
57
|
+
stream: false,
|
|
58
|
+
output: {
|
|
59
|
+
schema,
|
|
60
|
+
...(options.schemaName !== undefined && { name: options.schemaName }),
|
|
61
|
+
...(options.schemaDescription !== undefined && { description: options.schemaDescription }),
|
|
62
|
+
},
|
|
63
|
+
}, (event) => this.report(plan, event));
|
|
64
|
+
if (output === undefined) {
|
|
65
|
+
throw new AiOutputError(`The model was still calling tools after ${plan.maxSteps} step(s), and gave no answer. Raise \`maxSteps\`.`, result.text);
|
|
66
|
+
}
|
|
67
|
+
return { ...result, object: output };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Streams text: the call starts now, and its answer can be sent to the browser as it is generated
|
|
71
|
+
* (`pipeUIMessageStreamToResponse(res)`), read (`textStream`) or awaited (`text`). Mistakes in the options throw
|
|
72
|
+
* here; failures of the call reach the stream.
|
|
73
|
+
*/
|
|
74
|
+
streamText(options) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const signal = options?.abortSignal ? AbortSignal.any([options.abortSignal, controller.signal]) : controller.signal;
|
|
77
|
+
const plan = this.plan(options, signal);
|
|
78
|
+
return new AiTextStream(controller, async (emit) => {
|
|
79
|
+
const { result, finish } = await runLoop(plan, { stream: true, emit }, (event) => this.report(plan, event));
|
|
80
|
+
emit(finish ?? { type: 'finish', finishReason: result.finishReason });
|
|
81
|
+
return result;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* @internal One model call of a durable run (`@nestjs/ai/workflows`), with `messages` so far: the tools are offered
|
|
86
|
+
* and their calls returned, not run. Retries, timeouts, fallback and usage events as for any call.
|
|
87
|
+
*/
|
|
88
|
+
async callModel(options, { index, messages, signal }) {
|
|
89
|
+
const plan = this.plan({ ...options, prompt: undefined, messages }, signal, false);
|
|
90
|
+
const { value, attempts } = await runStep(plan, async (attempt) => {
|
|
91
|
+
const outcome = await generateStep(plan, index, plan.messages, attempt);
|
|
92
|
+
this.report(plan, usageEvent(plan, attempt, outcome, false));
|
|
93
|
+
return outcome;
|
|
94
|
+
});
|
|
95
|
+
return { outcome: value, attempts };
|
|
96
|
+
}
|
|
97
|
+
plan(options, signal, toolExecution = true) {
|
|
98
|
+
return createCallPlan(options, {
|
|
99
|
+
models: this.models,
|
|
100
|
+
tools: this.tools,
|
|
101
|
+
moduleOptions: this.options,
|
|
102
|
+
signal,
|
|
103
|
+
preset: (name) => this.preset(name),
|
|
104
|
+
toolExecution,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** A preset of the application's `ResilienceService`, which `ResilienceModule.forRoot({ presets })` configures. */
|
|
108
|
+
preset(name) {
|
|
109
|
+
let service;
|
|
110
|
+
try {
|
|
111
|
+
service = this.moduleRef.get(ResilienceService, { strict: false });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new AiConfigurationError(`\`resilience: '${name}'\` names a preset of ResilienceService, and the application does not import ResilienceModule.`);
|
|
115
|
+
}
|
|
116
|
+
return service.preset(name);
|
|
117
|
+
}
|
|
118
|
+
/** Calls the usage listeners. They never fail the call: what they throw is logged. */
|
|
119
|
+
report(plan, event) {
|
|
120
|
+
for (const listener of plan.listeners) {
|
|
121
|
+
try {
|
|
122
|
+
Promise.resolve(listener(event)).catch((error) => this.logListenerError(error));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.logListenerError(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
logListenerError(error) {
|
|
130
|
+
this.logger.error(`onUsage failed: ${error?.message ?? String(error)}`, error?.stack);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
AiService = __decorate([
|
|
134
|
+
Injectable(),
|
|
135
|
+
__param(2, Inject(AI_MODULE_OPTIONS)),
|
|
136
|
+
__metadata("design:paramtypes", [AiModels,
|
|
137
|
+
AiTools, Object, ModuleRef])
|
|
138
|
+
], AiService);
|
|
139
|
+
export { AiService };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { APICallError } from 'ai';
|
|
2
|
+
import { MockLanguageModelV4 } from 'ai/test';
|
|
3
|
+
import type { AiUsage } from '../interfaces/ai-usage.interface.js';
|
|
4
|
+
type CallOptions = Parameters<MockLanguageModelV4['doGenerate']>[0];
|
|
5
|
+
type GenerateResult = Awaited<ReturnType<MockLanguageModelV4['doGenerate']>>;
|
|
6
|
+
type FinishReason = GenerateResult['finishReason']['unified'];
|
|
7
|
+
/** A tool call the fake model makes. */
|
|
8
|
+
export interface FakeToolCall {
|
|
9
|
+
/** The tool's name. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Its arguments. Default `{}`. */
|
|
12
|
+
input?: unknown;
|
|
13
|
+
/** Default `call-<n>`. */
|
|
14
|
+
id?: string;
|
|
15
|
+
}
|
|
16
|
+
/** One answer of the fake model. */
|
|
17
|
+
export interface FakeTurn {
|
|
18
|
+
text?: string;
|
|
19
|
+
/** Answered as JSON text: what `generateObject()` reads. */
|
|
20
|
+
object?: unknown;
|
|
21
|
+
toolCalls?: FakeToolCall[];
|
|
22
|
+
/** Default: 10 input and 10 output tokens. */
|
|
23
|
+
usage?: Partial<Pick<AiUsage, 'inputTokens' | 'outputTokens' | 'cachedInputTokens' | 'reasoningTokens'>>;
|
|
24
|
+
/** Default `tool-calls` with tool calls, else `stop`. */
|
|
25
|
+
finishReason?: FinishReason;
|
|
26
|
+
/** Milliseconds before the model answers (a stream: before it opens). The call's abort cuts it short. */
|
|
27
|
+
delay?: number;
|
|
28
|
+
/** Milliseconds between a stream's chunks. */
|
|
29
|
+
chunkDelay?: number;
|
|
30
|
+
/** A stream fails with this error after its text, as a connection that breaks does. */
|
|
31
|
+
streamError?: unknown;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What the fake model answers a call with: text, a turn, an error (thrown, as a provider's failure: see
|
|
35
|
+
* `providerError()`), or a function of the call that returns one of these.
|
|
36
|
+
*/
|
|
37
|
+
export type FakeAnswer = string | FakeTurn | Error | ((call: FakeModelCall) => FakeAnswer | Promise<FakeAnswer>);
|
|
38
|
+
/** A call the fake model received. */
|
|
39
|
+
export interface FakeModelCall {
|
|
40
|
+
stream: boolean;
|
|
41
|
+
/** The system prompt. */
|
|
42
|
+
instructions?: string;
|
|
43
|
+
/** The text of the last user message. */
|
|
44
|
+
lastUserMessage?: string;
|
|
45
|
+
/** The results of the tools called in the previous step, as the model reads them. */
|
|
46
|
+
toolResults: Array<{
|
|
47
|
+
toolName: string;
|
|
48
|
+
toolCallId: string;
|
|
49
|
+
output: unknown;
|
|
50
|
+
}>;
|
|
51
|
+
/** The names of the tools offered. */
|
|
52
|
+
tools: string[];
|
|
53
|
+
/** Everything the model was sent (`ai`'s provider-level call options). */
|
|
54
|
+
options: CallOptions;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A language model for tests, with no network: it answers each call with the next of its `answers` (text, tool
|
|
58
|
+
* calls, objects, errors, delays), and records the calls. Pass a function instead of a list to answer every call
|
|
59
|
+
* with it.
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* const model = new FakeModel([
|
|
63
|
+
* { toolCalls: [{ name: 'find-order', input: { orderId: 'ord_1' } }] },
|
|
64
|
+
* 'Your order ships tomorrow.',
|
|
65
|
+
* ]);
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export declare class FakeModel extends MockLanguageModelV4 {
|
|
69
|
+
readonly calls: FakeModelCall[];
|
|
70
|
+
private readonly answers;
|
|
71
|
+
private readonly answerAll;
|
|
72
|
+
private toolCallCount;
|
|
73
|
+
constructor(answers?: FakeAnswer[] | ((call: FakeModelCall) => FakeAnswer | Promise<FakeAnswer>), { provider, modelId }?: {
|
|
74
|
+
modelId?: string | undefined;
|
|
75
|
+
provider?: string | undefined;
|
|
76
|
+
});
|
|
77
|
+
/** Adds answers, after the ones not used yet. */
|
|
78
|
+
reply(...answers: FakeAnswer[]): this;
|
|
79
|
+
/** The last call, or `undefined`. */
|
|
80
|
+
get lastCall(): FakeModelCall | undefined;
|
|
81
|
+
private answer;
|
|
82
|
+
private generate;
|
|
83
|
+
private stream;
|
|
84
|
+
private toolCalls;
|
|
85
|
+
}
|
|
86
|
+
/** `new FakeModel(answers)`. */
|
|
87
|
+
export declare function fakeModel(...answers: FakeAnswer[]): FakeModel;
|
|
88
|
+
/**
|
|
89
|
+
* A provider's HTTP error, as `@ai-sdk/*` providers throw it: retryable for 408, 409, 429 and 5xx. `retryAfter` (in
|
|
90
|
+
* seconds) is sent as a `retry-after` header.
|
|
91
|
+
*/
|
|
92
|
+
export declare function providerError(status: number, { message, retryAfter }?: {
|
|
93
|
+
message?: string;
|
|
94
|
+
retryAfter?: number;
|
|
95
|
+
}): APICallError;
|
|
96
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { APICallError } from 'ai';
|
|
2
|
+
import { MockLanguageModelV4 } from 'ai/test';
|
|
3
|
+
/**
|
|
4
|
+
* A language model for tests, with no network: it answers each call with the next of its `answers` (text, tool
|
|
5
|
+
* calls, objects, errors, delays), and records the calls. Pass a function instead of a list to answer every call
|
|
6
|
+
* with it.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const model = new FakeModel([
|
|
10
|
+
* { toolCalls: [{ name: 'find-order', input: { orderId: 'ord_1' } }] },
|
|
11
|
+
* 'Your order ships tomorrow.',
|
|
12
|
+
* ]);
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export class FakeModel extends MockLanguageModelV4 {
|
|
16
|
+
constructor(answers = [], { provider = 'fake', modelId = 'fake-model' } = {}) {
|
|
17
|
+
super({ provider, modelId });
|
|
18
|
+
this.calls = [];
|
|
19
|
+
this.toolCallCount = 0;
|
|
20
|
+
this.answers = Array.isArray(answers) ? [...answers] : [];
|
|
21
|
+
this.answerAll = typeof answers === 'function' ? answers : undefined;
|
|
22
|
+
this.doGenerate = async (options) => this.generate(await this.answer(options, false), options);
|
|
23
|
+
this.doStream = async (options) => this.stream(await this.answer(options, true), options);
|
|
24
|
+
}
|
|
25
|
+
/** Adds answers, after the ones not used yet. */
|
|
26
|
+
reply(...answers) {
|
|
27
|
+
this.answers.push(...answers);
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
/** The last call, or `undefined`. */
|
|
31
|
+
get lastCall() {
|
|
32
|
+
return this.calls[this.calls.length - 1];
|
|
33
|
+
}
|
|
34
|
+
async answer(options, stream) {
|
|
35
|
+
const call = describeCall(options, stream);
|
|
36
|
+
this.calls.push(call);
|
|
37
|
+
let answer = this.answerAll ?? this.answers.shift();
|
|
38
|
+
if (answer === undefined) {
|
|
39
|
+
throw new Error(`FakeModel: no answer left for call ${this.calls.length}. Give it more answers.`);
|
|
40
|
+
}
|
|
41
|
+
while (typeof answer === 'function') {
|
|
42
|
+
answer = await answer(call);
|
|
43
|
+
}
|
|
44
|
+
if (answer instanceof Error) {
|
|
45
|
+
throw answer;
|
|
46
|
+
}
|
|
47
|
+
const turn = typeof answer === 'string' ? { text: answer } : answer;
|
|
48
|
+
if (turn.delay) {
|
|
49
|
+
await sleep(turn.delay, options.abortSignal);
|
|
50
|
+
}
|
|
51
|
+
return turn;
|
|
52
|
+
}
|
|
53
|
+
generate(turn, options) {
|
|
54
|
+
options.abortSignal?.throwIfAborted();
|
|
55
|
+
const text = textOf(turn);
|
|
56
|
+
const toolCalls = this.toolCalls(turn);
|
|
57
|
+
return {
|
|
58
|
+
content: [...(text ? [{ type: 'text', text }] : []), ...toolCalls],
|
|
59
|
+
finishReason: finishOf(turn, toolCalls.length),
|
|
60
|
+
usage: usageOf(turn),
|
|
61
|
+
warnings: [],
|
|
62
|
+
response: { id: `response-${this.calls.length}`, modelId: this.modelId, timestamp: new Date(0) },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
stream(turn, options) {
|
|
66
|
+
const text = textOf(turn);
|
|
67
|
+
const toolCalls = this.toolCalls(turn);
|
|
68
|
+
const parts = [
|
|
69
|
+
{ type: 'stream-start', warnings: [] },
|
|
70
|
+
{ type: 'response-metadata', id: `response-${this.calls.length}`, modelId: this.modelId, timestamp: new Date(0) },
|
|
71
|
+
];
|
|
72
|
+
if (text) {
|
|
73
|
+
parts.push({ type: 'text-start', id: 'text-0' });
|
|
74
|
+
for (const delta of text.match(/\S+\s*|\s+/g) ?? []) {
|
|
75
|
+
parts.push({ type: 'text-delta', id: 'text-0', delta });
|
|
76
|
+
}
|
|
77
|
+
parts.push({ type: 'text-end', id: 'text-0' });
|
|
78
|
+
}
|
|
79
|
+
parts.push(...toolCalls);
|
|
80
|
+
if (turn.streamError !== undefined) {
|
|
81
|
+
parts.push({ type: 'error', error: turn.streamError });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
parts.push({ type: 'finish', finishReason: finishOf(turn, toolCalls.length), usage: usageOf(turn) });
|
|
85
|
+
}
|
|
86
|
+
const signal = options.abortSignal;
|
|
87
|
+
let index = 0;
|
|
88
|
+
return {
|
|
89
|
+
stream: new ReadableStream({
|
|
90
|
+
pull: async (controller) => {
|
|
91
|
+
if (index > 0 && turn.chunkDelay) {
|
|
92
|
+
try {
|
|
93
|
+
await sleep(turn.chunkDelay, signal);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
controller.error(error);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (signal?.aborted) {
|
|
101
|
+
controller.error(signal.reason);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const part = parts[index++];
|
|
105
|
+
if (part === undefined) {
|
|
106
|
+
controller.close();
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
controller.enqueue(part);
|
|
110
|
+
if (part.type === 'error') {
|
|
111
|
+
controller.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
toolCalls(turn) {
|
|
119
|
+
return (turn.toolCalls ?? []).map((call) => ({
|
|
120
|
+
type: 'tool-call',
|
|
121
|
+
toolCallId: call.id ?? `call-${++this.toolCallCount}`,
|
|
122
|
+
toolName: call.name,
|
|
123
|
+
input: JSON.stringify(call.input ?? {}),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** `new FakeModel(answers)`. */
|
|
128
|
+
export function fakeModel(...answers) {
|
|
129
|
+
return new FakeModel(answers);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A provider's HTTP error, as `@ai-sdk/*` providers throw it: retryable for 408, 409, 429 and 5xx. `retryAfter` (in
|
|
133
|
+
* seconds) is sent as a `retry-after` header.
|
|
134
|
+
*/
|
|
135
|
+
export function providerError(status, { message, retryAfter } = {}) {
|
|
136
|
+
return new APICallError({
|
|
137
|
+
message: message ?? `The provider answered ${status}`,
|
|
138
|
+
url: 'https://fake-provider.test/v1/messages',
|
|
139
|
+
requestBodyValues: {},
|
|
140
|
+
statusCode: status,
|
|
141
|
+
...(retryAfter !== undefined && { responseHeaders: { 'retry-after': String(retryAfter) } }),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function textOf(turn) {
|
|
145
|
+
return turn.object !== undefined ? JSON.stringify(turn.object) : (turn.text ?? '');
|
|
146
|
+
}
|
|
147
|
+
function finishOf(turn, toolCalls) {
|
|
148
|
+
const unified = turn.finishReason ?? (toolCalls > 0 ? 'tool-calls' : 'stop');
|
|
149
|
+
return { unified, raw: unified };
|
|
150
|
+
}
|
|
151
|
+
function usageOf(turn) {
|
|
152
|
+
const input = turn.usage?.inputTokens ?? 10;
|
|
153
|
+
const output = turn.usage?.outputTokens ?? 10;
|
|
154
|
+
const cached = turn.usage?.cachedInputTokens ?? 0;
|
|
155
|
+
const reasoning = turn.usage?.reasoningTokens ?? 0;
|
|
156
|
+
return {
|
|
157
|
+
inputTokens: { total: input, noCache: input - cached, cacheRead: cached, cacheWrite: undefined },
|
|
158
|
+
outputTokens: { total: output, text: output - reasoning, reasoning },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function describeCall(options, stream) {
|
|
162
|
+
const prompt = options.prompt;
|
|
163
|
+
const system = prompt.filter((message) => message.role === 'system').map((message) => message.content);
|
|
164
|
+
const lastUser = [...prompt].reverse().find((message) => message.role === 'user');
|
|
165
|
+
const trailingTools = [];
|
|
166
|
+
for (let index = prompt.length - 1; index >= 0 && prompt[index].role === 'tool'; index--) {
|
|
167
|
+
trailingTools.unshift(prompt[index]);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
stream,
|
|
171
|
+
...(system.length > 0 && { instructions: system.join('\n') }),
|
|
172
|
+
...(lastUser && {
|
|
173
|
+
lastUserMessage: lastUser.content
|
|
174
|
+
.filter((part) => part.type === 'text')
|
|
175
|
+
.map((part) => part.text)
|
|
176
|
+
.join(''),
|
|
177
|
+
}),
|
|
178
|
+
toolResults: trailingTools.flatMap((message) => message.content
|
|
179
|
+
.filter((part) => part.type === 'tool-result')
|
|
180
|
+
.map((part) => ({ toolName: part.toolName, toolCallId: part.toolCallId, output: part.output?.value }))),
|
|
181
|
+
tools: (options.tools ?? []).map((tool) => tool.name),
|
|
182
|
+
options,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function sleep(ms, signal) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
if (signal?.aborted) {
|
|
188
|
+
reject(signal.reason);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const timer = setTimeout(() => {
|
|
192
|
+
signal?.removeEventListener('abort', onAbort);
|
|
193
|
+
resolve();
|
|
194
|
+
}, ms);
|
|
195
|
+
const onAbort = () => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
reject(signal.reason);
|
|
198
|
+
};
|
|
199
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nestjs/ai/testing`: a fake model, and helpers for tests of applications that use `AiModule`.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* const model = new FakeModel(['Your order ships tomorrow.']);
|
|
6
|
+
* const moduleRef = await overrideModels(Test.createTestingModule({ imports: [AppModule] }), { default: model }).compile();
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
import type { INestApplicationContext } from '@nestjs/common';
|
|
10
|
+
import type { TestingModuleBuilder } from '@nestjs/testing';
|
|
11
|
+
import type { LanguageModel } from 'ai';
|
|
12
|
+
import type { AiCaller } from '../interfaces/ai-tool.interface.js';
|
|
13
|
+
export { FakeModel, fakeModel, providerError, type FakeAnswer, type FakeModelCall, type FakeToolCall, type FakeTurn } from './fake-model.js';
|
|
14
|
+
/**
|
|
15
|
+
* Replaces configured models by name (`{ default: new FakeModel([...]) }`) in a testing module, keeping their other
|
|
16
|
+
* options (pricing, fallback, timeouts). A name that is not configured fails at compile time.
|
|
17
|
+
*/
|
|
18
|
+
export declare function overrideModels(builder: TestingModuleBuilder, models: Record<string, LanguageModel>): TestingModuleBuilder;
|
|
19
|
+
/**
|
|
20
|
+
* Calls the agent function `name` with `input` as `caller`, as an agent's call does: the input validated by its
|
|
21
|
+
* schema, its scopes checked, then its guards, interceptors, pipes and filters, then the method. Resolves with what the
|
|
22
|
+
* model would read; rejects with the `AiToolError` it would read (a guard's `Forbidden resource`).
|
|
23
|
+
*/
|
|
24
|
+
export declare function callTool(app: INestApplicationContext, name: string, input?: unknown, { caller }?: {
|
|
25
|
+
caller?: AiCaller;
|
|
26
|
+
}): Promise<unknown>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AI_MODEL_OVERRIDES } from '../ai.constants.js';
|
|
2
|
+
import { AiTools } from '../tools/ai-tools.service.js';
|
|
3
|
+
export { FakeModel, fakeModel, providerError } from './fake-model.js';
|
|
4
|
+
/**
|
|
5
|
+
* Replaces configured models by name (`{ default: new FakeModel([...]) }`) in a testing module, keeping their other
|
|
6
|
+
* options (pricing, fallback, timeouts). A name that is not configured fails at compile time.
|
|
7
|
+
*/
|
|
8
|
+
export function overrideModels(builder, models) {
|
|
9
|
+
return builder.overrideProvider(AI_MODEL_OVERRIDES).useValue(models);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Calls the agent function `name` with `input` as `caller`, as an agent's call does: the input validated by its
|
|
13
|
+
* schema, its scopes checked, then its guards, interceptors, pipes and filters, then the method. Resolves with what the
|
|
14
|
+
* model would read; rejects with the `AiToolError` it would read (a guard's `Forbidden resource`).
|
|
15
|
+
*/
|
|
16
|
+
export async function callTool(app, name, input = {}, { caller } = {}) {
|
|
17
|
+
await app.init();
|
|
18
|
+
return app.get(AiTools, { strict: false }).call(name, input, caller);
|
|
19
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService, ExternalContextCreator, MetadataScanner } from '@nestjs/core';
|
|
3
|
+
import type { Schema, ToolSet } from 'ai';
|
|
4
|
+
import type { AgentFunctionContext } from '../interfaces/agent-function.interface.js';
|
|
5
|
+
import type { AiModuleOptions } from '../interfaces/ai-module-options.interface.js';
|
|
6
|
+
import type { AiCaller, AiToolSelection } from '../interfaces/ai-tool.interface.js';
|
|
7
|
+
/** A method behind Nest's enhancers: called as `(input, context)`. */
|
|
8
|
+
export type AiToolHandler = (input: unknown, context: AgentFunctionContext) => Promise<unknown>;
|
|
9
|
+
/** An agent function of the application: a provider method with `@AgentFunction()`, offered to models as a tool. */
|
|
10
|
+
export interface AiToolEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
scopes?: string[];
|
|
14
|
+
/** The `ai` schema of its arguments. */
|
|
15
|
+
inputSchema: Schema<unknown>;
|
|
16
|
+
handler: AiToolHandler;
|
|
17
|
+
/** The provider class it is a method of. */
|
|
18
|
+
host: Function;
|
|
19
|
+
/** `Class.method`, for messages. */
|
|
20
|
+
where: string;
|
|
21
|
+
}
|
|
22
|
+
/** An agent function's progress (`ctx.reportProgress()`), which a streamed answer sends as a transient data chunk. */
|
|
23
|
+
export interface AiToolProgress {
|
|
24
|
+
toolCallId: string;
|
|
25
|
+
toolName: string;
|
|
26
|
+
progress: number;
|
|
27
|
+
total?: number;
|
|
28
|
+
message?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The application's agent functions: the `@AgentFunction()` methods of its providers (or of the `include` modules),
|
|
32
|
+
* found once at startup, each wrapped by Nest's enhancers with the context type `'ai'`, and called with an
|
|
33
|
+
* `AgentFunctionContext`. `select()` turns some of them into `ai` tools for one caller.
|
|
34
|
+
*/
|
|
35
|
+
export declare class AiTools implements OnModuleInit {
|
|
36
|
+
private readonly discovery;
|
|
37
|
+
private readonly scanner;
|
|
38
|
+
private readonly options;
|
|
39
|
+
private readonly logger;
|
|
40
|
+
private readonly entries;
|
|
41
|
+
private readonly paramsFactory;
|
|
42
|
+
private readonly contextCreator;
|
|
43
|
+
private scanned;
|
|
44
|
+
constructor(discovery: DiscoveryService, scanner: MetadataScanner, externalContextCreator: ExternalContextCreator, options: AiModuleOptions);
|
|
45
|
+
onModuleInit(): void;
|
|
46
|
+
/** Every agent function, by name. */
|
|
47
|
+
get tools(): ReadonlyMap<string, AiToolEntry>;
|
|
48
|
+
/**
|
|
49
|
+
* `ai` tools for `names` (agent function names, or provider classes for all their agent functions), whose calls run
|
|
50
|
+
* as `caller`: what `AiService` calls pass the model, and what code that calls `ai` itself can pass `generateText()`.
|
|
51
|
+
*/
|
|
52
|
+
select(selection: AiToolSelection, { caller, onExecute, onProgress, execute, }?: {
|
|
53
|
+
caller?: AiCaller;
|
|
54
|
+
/** @internal */
|
|
55
|
+
onExecute?: () => void;
|
|
56
|
+
/** @internal Where `ctx.reportProgress()` goes: a streamed answer. */
|
|
57
|
+
onProgress?: (progress: AiToolProgress) => void;
|
|
58
|
+
/** @internal `false`: the tools are offered to the model, and their calls returned, not run (durable runs). */
|
|
59
|
+
execute?: boolean;
|
|
60
|
+
}): ToolSet;
|
|
61
|
+
/**
|
|
62
|
+
* Runs the agent function `name` with `input` (validated by its schema) as `caller`, through its enhancers, as an
|
|
63
|
+
* agent's call does. Resolves with what the model would read; rejects with the `AiToolError` it would read.
|
|
64
|
+
*/
|
|
65
|
+
call(entryOrName: AiToolEntry | string, input: unknown, caller?: AiCaller, options?: {
|
|
66
|
+
toolCallId?: string;
|
|
67
|
+
messages?: unknown[];
|
|
68
|
+
abortSignal?: AbortSignal;
|
|
69
|
+
idempotencyKey?: string;
|
|
70
|
+
onProgress?: (progress: AiToolProgress) => void;
|
|
71
|
+
}): Promise<unknown>;
|
|
72
|
+
/** The agent functions a selection names, each once, in order. Unknown names throw. */
|
|
73
|
+
resolve(selection: AiToolSelection): AiToolEntry[];
|
|
74
|
+
/** Finds the agent functions, once. */
|
|
75
|
+
scan(): void;
|
|
76
|
+
private known;
|
|
77
|
+
}
|