@nestjs/ai 0.0.0-stage → 0.0.2
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 +30 -0
- package/dist/ai.constants.js +34 -0
- package/dist/ai.module-definition.d.ts +23 -0
- package/dist/ai.module-definition.js +39 -0
- package/dist/ai.module.d.ts +11 -0
- package/dist/ai.module.js +45 -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 +3 -0
- package/dist/decorators/index.js +3 -0
- package/dist/decorators/inject-embedding-model.decorator.d.ts +10 -0
- package/dist/decorators/inject-embedding-model.decorator.js +16 -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 +14 -0
- package/dist/index.js +16 -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-embedding.interface.d.ts +79 -0
- package/dist/interfaces/ai-embedding.interface.js +1 -0
- package/dist/interfaces/ai-module-options.interface.d.ts +110 -0
- package/dist/interfaces/ai-module-options.interface.js +1 -0
- package/dist/interfaces/ai-result.interface.d.ts +57 -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 +8 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/models/ai-embedding-models.service.d.ts +31 -0
- package/dist/models/ai-embedding-models.service.js +138 -0
- package/dist/models/ai-models.service.d.ts +29 -0
- package/dist/models/ai-models.service.js +112 -0
- package/dist/models/model-registry.util.d.ts +6 -0
- package/dist/models/model-registry.util.js +21 -0
- package/dist/models/model-requests.d.ts +5 -0
- package/dist/models/model-requests.js +20 -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/embedding-call.d.ts +38 -0
- package/dist/runtime/embedding-call.js +120 -0
- package/dist/runtime/model-policy.d.ts +53 -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 +70 -0
- package/dist/services/ai.service.js +183 -0
- package/dist/testing/fake-embedding-model.d.ts +57 -0
- package/dist/testing/fake-embedding-model.js +82 -0
- package/dist/testing/fake-model.d.ts +96 -0
- package/dist/testing/fake-model.js +201 -0
- package/dist/testing/index.d.ts +32 -0
- package/dist/testing/index.js +27 -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 +115 -4
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { AiBudgetExceededError } from '../errors/ai.errors.js';
|
|
2
|
+
import { addCost, addUsage, ZERO_USAGE } from '../utils/usage.util.js';
|
|
3
|
+
import { runStep } from './model-policy.js';
|
|
4
|
+
import { generateStep, streamStep, wantsAnotherStep } from './steps.js';
|
|
5
|
+
/**
|
|
6
|
+
* The agent loop: a model call, then, while the model asks for tools (which run inside the call), another one with
|
|
7
|
+
* the conversation so far, up to `maxSteps`. Each model call runs under its resilience policies (retry, timeout,
|
|
8
|
+
* fallback); the budget is checked before each. `report` receives a usage event per model call that answered.
|
|
9
|
+
*/
|
|
10
|
+
export async function runLoop(plan, mode, report) {
|
|
11
|
+
const messages = [...plan.messages];
|
|
12
|
+
const responseMessages = [];
|
|
13
|
+
const steps = [];
|
|
14
|
+
let usage = ZERO_USAGE;
|
|
15
|
+
let cost;
|
|
16
|
+
let last;
|
|
17
|
+
for (let index = 0; index < plan.maxSteps; index++) {
|
|
18
|
+
assertWithinBudget(plan, usage, cost);
|
|
19
|
+
const conversation = [...messages];
|
|
20
|
+
const run = async (attempt) => {
|
|
21
|
+
const outcome = mode.stream
|
|
22
|
+
? await streamStep(plan, index, conversation, attempt)
|
|
23
|
+
: await generateStep(plan, index, conversation, attempt, mode.output);
|
|
24
|
+
report(usageEvent(plan, attempt, outcome, mode.stream));
|
|
25
|
+
return outcome;
|
|
26
|
+
};
|
|
27
|
+
const { value, attempts } = await runStep(plan, run, mode.stream ? (chunk) => mode.emit(chunk) : undefined);
|
|
28
|
+
last = value;
|
|
29
|
+
steps.push({ ...value.step, attempts });
|
|
30
|
+
usage = addUsage(usage, value.step.usage);
|
|
31
|
+
cost = addCost(cost, value.step.cost);
|
|
32
|
+
messages.push(...value.responseMessages);
|
|
33
|
+
responseMessages.push(...value.responseMessages);
|
|
34
|
+
if (!wantsAnotherStep(value)) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const final = last;
|
|
39
|
+
return {
|
|
40
|
+
result: {
|
|
41
|
+
text: final.step.text,
|
|
42
|
+
finishReason: final.step.finishReason,
|
|
43
|
+
steps,
|
|
44
|
+
usage,
|
|
45
|
+
...(cost !== undefined && { cost }),
|
|
46
|
+
toolCalls: steps.flatMap((step) => step.toolCalls),
|
|
47
|
+
toolResults: steps.flatMap((step) => step.toolResults),
|
|
48
|
+
responseMessages,
|
|
49
|
+
model: final.step.model,
|
|
50
|
+
},
|
|
51
|
+
...('output' in final && { output: final.output }),
|
|
52
|
+
...(final.finish && { finish: final.finish }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** The usage event of a model call that answered. */
|
|
56
|
+
export function usageEvent(plan, attempt, outcome, stream) {
|
|
57
|
+
return {
|
|
58
|
+
...(plan.label !== undefined && { label: plan.label }),
|
|
59
|
+
model: attempt.model.info,
|
|
60
|
+
step: outcome.step.index,
|
|
61
|
+
attempt: attempt.attempt,
|
|
62
|
+
fallback: attempt.fallback,
|
|
63
|
+
stream,
|
|
64
|
+
usage: outcome.step.usage,
|
|
65
|
+
...(outcome.step.cost !== undefined && { cost: outcome.step.cost }),
|
|
66
|
+
durationMs: Math.round(outcome.durationMs),
|
|
67
|
+
finishReason: outcome.step.finishReason,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Throws when the call has spent its budget. */
|
|
71
|
+
export function assertWithinBudget(plan, usage, cost) {
|
|
72
|
+
const budget = plan.budget;
|
|
73
|
+
if (!budget) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const overTokens = budget.maxTokens !== undefined && usage.totalTokens >= budget.maxTokens;
|
|
77
|
+
const overCost = budget.maxCost !== undefined && (cost ?? 0) >= budget.maxCost;
|
|
78
|
+
if (overTokens || overCost) {
|
|
79
|
+
throw new AiBudgetExceededError(usage, cost, budget);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ResiliencePolicy } from '@nestjs/resilience';
|
|
2
|
+
import type { ModelMessage, ToolSet } from 'ai';
|
|
3
|
+
import type { AiCallOptions, AiToolChoice } from '../interfaces/ai-call-options.interface.js';
|
|
4
|
+
import type { AiBudget, AiModuleOptions } from '../interfaces/ai-module-options.interface.js';
|
|
5
|
+
import type { AiCaller } from '../interfaces/ai-tool.interface.js';
|
|
6
|
+
import type { AiUsageEvent } from '../interfaces/ai-usage.interface.js';
|
|
7
|
+
import type { AiModels, ResolvedModel } from '../models/ai-models.service.js';
|
|
8
|
+
import type { AiTools } from '../tools/ai-tools.service.js';
|
|
9
|
+
import { type PolicyPlan, type StepAttempt } from './model-policy.js';
|
|
10
|
+
/** The generation settings of one model call, as `generateText()` and `streamText()` take them. */
|
|
11
|
+
export interface GenerationSettings {
|
|
12
|
+
maxOutputTokens?: number;
|
|
13
|
+
temperature?: number;
|
|
14
|
+
topP?: number;
|
|
15
|
+
topK?: number;
|
|
16
|
+
seed?: number;
|
|
17
|
+
stopSequences?: string[];
|
|
18
|
+
presencePenalty?: number;
|
|
19
|
+
frequencyPenalty?: number;
|
|
20
|
+
providerOptions?: Record<string, Record<string, any>>;
|
|
21
|
+
headers?: Record<string, string | undefined>;
|
|
22
|
+
}
|
|
23
|
+
/** Everything a call needs, resolved once from its options, the models and the module's defaults. Internal. */
|
|
24
|
+
export interface CallPlan extends PolicyPlan {
|
|
25
|
+
label?: string;
|
|
26
|
+
maxSteps: number;
|
|
27
|
+
budget?: AiBudget;
|
|
28
|
+
instructions?: string;
|
|
29
|
+
messages: ModelMessage[];
|
|
30
|
+
tools?: ToolSet;
|
|
31
|
+
toolChoice?: AiToolChoice;
|
|
32
|
+
caller: AiCaller;
|
|
33
|
+
listeners: Array<(event: AiUsageEvent) => void | Promise<void>>;
|
|
34
|
+
settingsFor(model: ResolvedModel): GenerationSettings;
|
|
35
|
+
/**
|
|
36
|
+
* The attempt whose tools may run, set by the step. A tool that starts means the model has answered: the attempt
|
|
37
|
+
* commits (no timeout, retry or fallback from then on), so that no tool runs twice.
|
|
38
|
+
*/
|
|
39
|
+
running?: StepAttempt;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolves a call's options. Mistakes (an unknown model or agent function, both `prompt` and `messages`) throw here,
|
|
43
|
+
* before anything is sent. `signal` is the call's: the caller's, or one combined with it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createCallPlan(options: AiCallOptions, { models, tools, moduleOptions, signal, preset, toolExecution, }: {
|
|
46
|
+
models: AiModels;
|
|
47
|
+
tools: AiTools;
|
|
48
|
+
moduleOptions: AiModuleOptions;
|
|
49
|
+
signal: AbortSignal;
|
|
50
|
+
/** A preset of `ResilienceService`, by name. */
|
|
51
|
+
preset: (name: string) => ResiliencePolicy;
|
|
52
|
+
/** `false`: tool calls are returned, not run (durable runs). */
|
|
53
|
+
toolExecution?: boolean;
|
|
54
|
+
}): CallPlan;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { AiConfigurationError } from '../errors/ai.errors.js';
|
|
2
|
+
import { modelPolicy } from './model-policy.js';
|
|
3
|
+
const DEFAULT_MAX_STEPS = 10;
|
|
4
|
+
/**
|
|
5
|
+
* Resolves a call's options. Mistakes (an unknown model or agent function, both `prompt` and `messages`) throw here,
|
|
6
|
+
* before anything is sent. `signal` is the call's: the caller's, or one combined with it.
|
|
7
|
+
*/
|
|
8
|
+
export function createCallPlan(options, { models, tools, moduleOptions, signal, preset, toolExecution = true, }) {
|
|
9
|
+
if (typeof options !== 'object' || options === null) {
|
|
10
|
+
throw new AiConfigurationError('Pass the call its options: { prompt } or { messages }, and the rest.');
|
|
11
|
+
}
|
|
12
|
+
const defaults = moduleOptions.defaults ?? {};
|
|
13
|
+
const primary = models.resolve(options.model);
|
|
14
|
+
const chain = [primary, ...fallbacks(options.fallback, primary, models)].filter((model, index, all) => all.findIndex((other) => other.model === model.model) === index);
|
|
15
|
+
const caller = { ...options.caller };
|
|
16
|
+
const plan = {};
|
|
17
|
+
const onExecute = () => plan.running?.commit();
|
|
18
|
+
// A streamed answer carries the agent functions' progress, as transient data chunks `useChat()` hands to `onData`.
|
|
19
|
+
const onProgress = (progress) => plan.running?.emit({ type: 'data-tool-progress', id: progress.toolCallId, data: progress, transient: true });
|
|
20
|
+
const selected = options.tools?.length
|
|
21
|
+
? tools.select(options.tools, { caller, onExecute, onProgress, execute: toolExecution })
|
|
22
|
+
: undefined;
|
|
23
|
+
const maxSteps = options.maxSteps ?? defaults.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
24
|
+
if (!Number.isInteger(maxSteps) || maxSteps < 1) {
|
|
25
|
+
throw new AiConfigurationError(`\`maxSteps\` must be a whole number of at least 1. Got ${String(maxSteps)}.`);
|
|
26
|
+
}
|
|
27
|
+
const policies = new Map();
|
|
28
|
+
const pick = (model, key) => options[key] ?? model.settings[key] ?? defaults[key];
|
|
29
|
+
return Object.assign(plan, {
|
|
30
|
+
...(options.label !== undefined && { label: options.label }),
|
|
31
|
+
chain,
|
|
32
|
+
signal,
|
|
33
|
+
maxSteps,
|
|
34
|
+
...((options.budget ?? defaults.budget) && { budget: options.budget ?? defaults.budget }),
|
|
35
|
+
...(options.instructions !== undefined && { instructions: options.instructions }),
|
|
36
|
+
messages: messagesOf(options),
|
|
37
|
+
...(selected && { tools: selected }),
|
|
38
|
+
...(options.toolChoice !== undefined && { toolChoice: options.toolChoice }),
|
|
39
|
+
caller,
|
|
40
|
+
listeners: [moduleOptions.onUsage, options.onUsage].filter((listener) => typeof listener === 'function'),
|
|
41
|
+
source: options.label ?? 'AiService',
|
|
42
|
+
fallbackIf: options.fallbackIf ?? (() => true),
|
|
43
|
+
policyFor: (model) => {
|
|
44
|
+
let policy = policies.get(model);
|
|
45
|
+
if (!policies.has(model)) {
|
|
46
|
+
const own = options.resilience ?? model.settings.resilience ?? defaults.resilience;
|
|
47
|
+
policy =
|
|
48
|
+
own === undefined
|
|
49
|
+
? modelPolicy(model, {
|
|
50
|
+
retry: options.retry ?? model.settings.retry ?? defaults.retry,
|
|
51
|
+
timeout: options.timeout ?? model.settings.timeout ?? defaults.timeout,
|
|
52
|
+
})
|
|
53
|
+
: typeof own === 'string'
|
|
54
|
+
? preset(own)
|
|
55
|
+
: own;
|
|
56
|
+
policies.set(model, policy);
|
|
57
|
+
}
|
|
58
|
+
return policy;
|
|
59
|
+
},
|
|
60
|
+
settingsFor: (model) => {
|
|
61
|
+
const settings = {};
|
|
62
|
+
for (const key of [
|
|
63
|
+
'maxOutputTokens',
|
|
64
|
+
'temperature',
|
|
65
|
+
'topP',
|
|
66
|
+
'topK',
|
|
67
|
+
'seed',
|
|
68
|
+
'stopSequences',
|
|
69
|
+
'presencePenalty',
|
|
70
|
+
'frequencyPenalty',
|
|
71
|
+
'providerOptions',
|
|
72
|
+
'headers',
|
|
73
|
+
]) {
|
|
74
|
+
const value = pick(model, key);
|
|
75
|
+
if (value !== undefined) {
|
|
76
|
+
settings[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return settings;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function fallbacks(option, primary, models) {
|
|
84
|
+
if (option === false) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
if (option === undefined) {
|
|
88
|
+
return primary.fallback.map((name) => models.resolve(name));
|
|
89
|
+
}
|
|
90
|
+
const references = Array.isArray(option) ? [...option] : [option];
|
|
91
|
+
return references.map((reference) => models.resolve(reference));
|
|
92
|
+
}
|
|
93
|
+
function messagesOf(options) {
|
|
94
|
+
const { prompt, messages } = options;
|
|
95
|
+
if (prompt !== undefined && messages !== undefined) {
|
|
96
|
+
throw new AiConfigurationError('Pass `prompt` or `messages`, not both.');
|
|
97
|
+
}
|
|
98
|
+
if (typeof prompt === 'string') {
|
|
99
|
+
return [{ role: 'user', content: prompt }];
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
102
|
+
return [...messages];
|
|
103
|
+
}
|
|
104
|
+
throw new AiConfigurationError('Pass `prompt` (a string) or `messages` (a non-empty array of model messages).');
|
|
105
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ResiliencePolicy } from '@nestjs/resilience';
|
|
2
|
+
import { type EmbedManyResult, type EmbedResult } from 'ai';
|
|
3
|
+
import type { AiEmbeddingCallOptions, AiEmbeddingSettings, AiEmbeddingUsageEvent } from '../interfaces/ai-embedding.interface.js';
|
|
4
|
+
import type { AiModuleOptions } from '../interfaces/ai-module-options.interface.js';
|
|
5
|
+
import type { AiEmbeddingModels, ResolvedEmbeddingModel } from '../models/ai-embedding-models.service.js';
|
|
6
|
+
import { type PolicyPlan } from './model-policy.js';
|
|
7
|
+
/** One operation's state. Clients and explicitly supplied resilience policies remain shared. */
|
|
8
|
+
export interface EmbeddingPlan extends PolicyPlan<ResolvedEmbeddingModel> {
|
|
9
|
+
model: ResolvedEmbeddingModel;
|
|
10
|
+
label?: string;
|
|
11
|
+
settings: Pick<AiEmbeddingSettings, 'headers' | 'providerOptions'> & {
|
|
12
|
+
maxParallelCalls: number;
|
|
13
|
+
};
|
|
14
|
+
listeners: Array<(event: AiEmbeddingUsageEvent) => void | Promise<void>>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolves only embedding settings: language generation defaults never reach an embedding provider.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createEmbeddingPlan(options: AiEmbeddingCallOptions, models: AiEmbeddingModels, moduleOptions: AiModuleOptions, preset: (name: string) => ResiliencePolicy): EmbeddingPlan;
|
|
20
|
+
interface EmbeddingExecution<R> {
|
|
21
|
+
result: R;
|
|
22
|
+
event: AiEmbeddingUsageEvent;
|
|
23
|
+
attempts: number;
|
|
24
|
+
cost?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The SDK splits `values` into provider requests. The policy runs around each of them, through the
|
|
28
|
+
* SDK's middleware, so a timeout or a retry covers one request and never repeats the others. When a
|
|
29
|
+
* request fails for good, its siblings are aborted, so that a failed operation does not keep
|
|
30
|
+
* consuming provider capacity.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runEmbedding(plan: EmbeddingPlan, input: {
|
|
33
|
+
value: string;
|
|
34
|
+
}): Promise<EmbeddingExecution<EmbedResult>>;
|
|
35
|
+
export declare function runEmbedding(plan: EmbeddingPlan, input: {
|
|
36
|
+
values: string[];
|
|
37
|
+
}): Promise<EmbeddingExecution<EmbedManyResult>>;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { embed, embedMany, gateway, wrapEmbeddingModel, } from 'ai';
|
|
2
|
+
import { AiConfigurationError } from '../errors/ai.errors.js';
|
|
3
|
+
import { modelPolicy, runStep } from './model-policy.js';
|
|
4
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
5
|
+
const DEFAULT_MAX_PARALLEL_CALLS = 4;
|
|
6
|
+
/**
|
|
7
|
+
* Resolves only embedding settings: language generation defaults never reach an embedding provider.
|
|
8
|
+
*/
|
|
9
|
+
export function createEmbeddingPlan(options, models, moduleOptions, preset) {
|
|
10
|
+
if (typeof options !== 'object' || options === null) {
|
|
11
|
+
throw new AiConfigurationError('Pass the embedding call its options: { value } or { values }, and the rest.');
|
|
12
|
+
}
|
|
13
|
+
const model = models.resolve(options.model);
|
|
14
|
+
const defaults = moduleOptions.embeddingDefaults ?? {};
|
|
15
|
+
const pick = (key) => options[key] ?? model.settings[key] ?? defaults[key];
|
|
16
|
+
const maxParallelCalls = pick('maxParallelCalls') ?? DEFAULT_MAX_PARALLEL_CALLS;
|
|
17
|
+
if (!Number.isSafeInteger(maxParallelCalls) || maxParallelCalls < 1) {
|
|
18
|
+
throw new AiConfigurationError('`maxParallelCalls` must be a finite positive whole number.');
|
|
19
|
+
}
|
|
20
|
+
const own = pick('resilience');
|
|
21
|
+
const policy = own === undefined
|
|
22
|
+
? modelPolicy(model, { retry: pick('retry'), timeout: pick('timeout') }, 'ai:embedding')
|
|
23
|
+
: typeof own === 'string'
|
|
24
|
+
? preset(own)
|
|
25
|
+
: own;
|
|
26
|
+
return {
|
|
27
|
+
model,
|
|
28
|
+
chain: [model],
|
|
29
|
+
signal: options.abortSignal ?? NEVER_ABORTED,
|
|
30
|
+
source: options.label ?? 'AiService',
|
|
31
|
+
fallbackIf: () => false,
|
|
32
|
+
policyFor: () => policy,
|
|
33
|
+
...(options.label !== undefined && { label: options.label }),
|
|
34
|
+
settings: {
|
|
35
|
+
maxParallelCalls,
|
|
36
|
+
...(pick('headers') !== undefined && { headers: pick('headers') }),
|
|
37
|
+
...(pick('providerOptions') !== undefined && { providerOptions: pick('providerOptions') }),
|
|
38
|
+
},
|
|
39
|
+
listeners: [moduleOptions.onEmbeddingUsage, options.onEmbeddingUsage].filter((listener) => typeof listener === 'function'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export async function runEmbedding(plan, input) {
|
|
43
|
+
const started = performance.now();
|
|
44
|
+
const many = 'values' in input;
|
|
45
|
+
if (many
|
|
46
|
+
? !Array.isArray(input.values) || [...input.values].some((value) => typeof value !== 'string')
|
|
47
|
+
: typeof input.value !== 'string') {
|
|
48
|
+
throw new AiConfigurationError(many ? '`values` must be an array of strings.' : '`value` must be a string.');
|
|
49
|
+
}
|
|
50
|
+
const values = many ? [...input.values] : undefined;
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const signal = AbortSignal.any([plan.signal, controller.signal]);
|
|
53
|
+
let attempts = 0;
|
|
54
|
+
const wrapped = wrapEmbeddingModel({
|
|
55
|
+
model: modelObject(plan.model.model),
|
|
56
|
+
middleware: {
|
|
57
|
+
wrapEmbed: async ({ params, model }) => {
|
|
58
|
+
const { value, attempts: used } = await runStep({ ...plan, signal }, async (attempt) => {
|
|
59
|
+
attempt.signal.throwIfAborted();
|
|
60
|
+
return model.doEmbed({ ...params, abortSignal: attempt.signal });
|
|
61
|
+
});
|
|
62
|
+
attempts += used;
|
|
63
|
+
return value;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
signal.throwIfAborted();
|
|
70
|
+
const settings = {
|
|
71
|
+
model: wrapped,
|
|
72
|
+
headers: plan.settings.headers,
|
|
73
|
+
providerOptions: plan.settings.providerOptions,
|
|
74
|
+
abortSignal: signal,
|
|
75
|
+
maxRetries: 0,
|
|
76
|
+
};
|
|
77
|
+
result =
|
|
78
|
+
values !== undefined
|
|
79
|
+
? await embedMany({
|
|
80
|
+
...settings,
|
|
81
|
+
values,
|
|
82
|
+
maxParallelCalls: plan.settings.maxParallelCalls,
|
|
83
|
+
})
|
|
84
|
+
: await embed({ ...settings, value: input.value });
|
|
85
|
+
signal.throwIfAborted();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
controller.abort();
|
|
89
|
+
}
|
|
90
|
+
const pricing = plan.model.pricing;
|
|
91
|
+
const cost = pricing && Number.isFinite(result.usage.tokens)
|
|
92
|
+
? (result.usage.tokens * pricing.input) / 1_000_000
|
|
93
|
+
: undefined;
|
|
94
|
+
return {
|
|
95
|
+
result,
|
|
96
|
+
attempts,
|
|
97
|
+
...(cost !== undefined && { cost }),
|
|
98
|
+
event: {
|
|
99
|
+
operation: many ? 'embedMany' : 'embed',
|
|
100
|
+
...(plan.label !== undefined && { label: plan.label }),
|
|
101
|
+
model: plan.model.info,
|
|
102
|
+
inputCount: values?.length ?? 1,
|
|
103
|
+
usage: { tokens: result.usage.tokens },
|
|
104
|
+
attempts,
|
|
105
|
+
durationMs: Math.round(performance.now() - started),
|
|
106
|
+
...(cost !== undefined && { cost }),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `wrapEmbeddingModel()` takes model objects. An id resolves as the SDK resolves it: with its
|
|
112
|
+
* default provider, the gateway unless the application set another.
|
|
113
|
+
*/
|
|
114
|
+
function modelObject(model) {
|
|
115
|
+
if (typeof model !== 'string') {
|
|
116
|
+
return model;
|
|
117
|
+
}
|
|
118
|
+
const provider = (globalThis.AI_SDK_DEFAULT_PROVIDER ?? gateway);
|
|
119
|
+
return provider.embeddingModel(model);
|
|
120
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ResiliencePolicy, type RetryOptions } from '@nestjs/resilience';
|
|
2
|
+
import type { ResolvedModel } from '../models/ai-models.service.js';
|
|
3
|
+
import type { AiModelInfo } from '../interfaces/ai-result.interface.js';
|
|
4
|
+
/** The identity the policy layer needs; generation and embedding registries stay separately typed. */
|
|
5
|
+
export interface PolicyModel {
|
|
6
|
+
info: AiModelInfo;
|
|
7
|
+
}
|
|
8
|
+
/** What one attempt of a model call receives. */
|
|
9
|
+
export interface StepAttempt<M extends PolicyModel = ResolvedModel> {
|
|
10
|
+
model: M;
|
|
11
|
+
/** 1-based, per model. */
|
|
12
|
+
attempt: number;
|
|
13
|
+
fallback: boolean;
|
|
14
|
+
/** Aborted by the caller, or by the attempt's timeout (reason: `ResilienceTimeoutError`). */
|
|
15
|
+
signal: AbortSignal;
|
|
16
|
+
/** Commits the attempt: no timeout, retry or fallback from now on. A step whose tools start calls it. */
|
|
17
|
+
commit(): void;
|
|
18
|
+
/** Sends a stream chunk to the consumer, which commits the attempt too. */
|
|
19
|
+
emit(chunk: unknown): void;
|
|
20
|
+
}
|
|
21
|
+
/** How a call's models are retried, timed out and replaced. Internal. */
|
|
22
|
+
export interface PolicyPlan<M extends PolicyModel = ResolvedModel> {
|
|
23
|
+
chain: M[];
|
|
24
|
+
/** The caller's signal. */
|
|
25
|
+
signal: AbortSignal;
|
|
26
|
+
/** Labels the policies' events (`source`). */
|
|
27
|
+
source: string;
|
|
28
|
+
/** The model's own policy: an application's, or the one built from its `retry` and `timeout`. */
|
|
29
|
+
policyFor(model: M): ResiliencePolicy | undefined;
|
|
30
|
+
fallbackIf: (error: unknown) => boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Runs one model call (a step) under the call's policies: the model's own (retry, timeout, or the application's),
|
|
34
|
+
* then each fallback model's, through `executeObservable()`, so that the outcome is committed once a chunk reaches
|
|
35
|
+
* the consumer or the step's tools start. Chunks go to `emit` as they come. When the call fails: `AiModelError` with
|
|
36
|
+
* every attempt, the caller's abort reason, or what is not a model failure (an `AiOutputError`, a circuit breaker's
|
|
37
|
+
* rejection) as it is.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runStep<T, M extends PolicyModel = ResolvedModel>(plan: PolicyPlan<M>, run: (attempt: StepAttempt<M>) => Promise<T>, emit?: (chunk: unknown) => void): Promise<{
|
|
40
|
+
value: T;
|
|
41
|
+
attempt: StepAttempt<M>;
|
|
42
|
+
attempts: number;
|
|
43
|
+
}>;
|
|
44
|
+
/**
|
|
45
|
+
* The model's own policy from its settings: `RetryPolicy` (honoring the provider's `retry-after`) around
|
|
46
|
+
* `TimeoutPolicy`, either one left out when it is `false`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function modelPolicy(model: PolicyModel, { retry, timeout }: {
|
|
49
|
+
retry: RetryOptions | false | undefined;
|
|
50
|
+
timeout: number | string | false | undefined;
|
|
51
|
+
}, namespace?: string): ResiliencePolicy | undefined;
|
|
52
|
+
/** What is retried by default: what the provider marks retryable (with network failures), and timeouts. */
|
|
53
|
+
export declare function isRetryable(error: unknown): boolean;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { FallbackPolicy, ResilienceContext, ResilienceError, ResiliencePolicy, ResilienceTimeoutError, RetryPolicy, TimeoutPolicy, } from '@nestjs/resilience';
|
|
2
|
+
import { Logger } from '@nestjs/common';
|
|
3
|
+
import { APICallError, RetryError } from 'ai';
|
|
4
|
+
import { Observable } from 'rxjs';
|
|
5
|
+
import { AiBudgetExceededError, AiConfigurationError, AiModelError, AiOutputError, } from '../errors/ai.errors.js';
|
|
6
|
+
import { toMilliseconds } from '../utils/duration.util.js';
|
|
7
|
+
const logger = new Logger('AiModule');
|
|
8
|
+
/** `ai`'s argument errors, which no retry or other model fixes. */
|
|
9
|
+
const CALLER_MISTAKES = new Set(['AI_InvalidPromptError', 'AI_InvalidArgumentError', 'AI_InvalidMessageRoleError', 'AI_MessageConversionError']);
|
|
10
|
+
/**
|
|
11
|
+
* Runs one model call (a step) under the call's policies: the model's own (retry, timeout, or the application's),
|
|
12
|
+
* then each fallback model's, through `executeObservable()`, so that the outcome is committed once a chunk reaches
|
|
13
|
+
* the consumer or the step's tools start. Chunks go to `emit` as they come. When the call fails: `AiModelError` with
|
|
14
|
+
* every attempt, the caller's abort reason, or what is not a model failure (an `AiOutputError`, a circuit breaker's
|
|
15
|
+
* rejection) as it is.
|
|
16
|
+
*/
|
|
17
|
+
export function runStep(plan, run, emit) {
|
|
18
|
+
const failures = [];
|
|
19
|
+
let attempts = 0;
|
|
20
|
+
const attemptsOf = (index) => (context) => new Observable((subscriber) => {
|
|
21
|
+
attempts++;
|
|
22
|
+
const model = plan.chain[index];
|
|
23
|
+
let settled = false;
|
|
24
|
+
let committed = false;
|
|
25
|
+
const record = (error) => {
|
|
26
|
+
if (!settled) {
|
|
27
|
+
settled = true;
|
|
28
|
+
// An attempt aborted for its timeout failed for the timeout, whatever the request threw.
|
|
29
|
+
const reason = context.signal.aborted ? context.signal.reason : undefined;
|
|
30
|
+
failures.push({ model: model.info, attempt: context.attempt, error: reason instanceof ResilienceTimeoutError ? reason : unwrap(error) });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const attempt = {
|
|
34
|
+
model,
|
|
35
|
+
attempt: context.attempt,
|
|
36
|
+
fallback: index > 0,
|
|
37
|
+
signal: context.signal,
|
|
38
|
+
commit: () => {
|
|
39
|
+
if (!committed && !subscriber.closed) {
|
|
40
|
+
committed = true;
|
|
41
|
+
subscriber.next({ type: 'commit' });
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
emit: (chunk) => {
|
|
45
|
+
committed = true;
|
|
46
|
+
subscriber.next({ type: 'chunk', chunk });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
run(attempt).then((value) => {
|
|
50
|
+
settled = true;
|
|
51
|
+
subscriber.next({ type: 'outcome', value, attempt });
|
|
52
|
+
subscriber.complete();
|
|
53
|
+
}, (error) => {
|
|
54
|
+
record(error);
|
|
55
|
+
subscriber.error(unwrap(error));
|
|
56
|
+
});
|
|
57
|
+
// Unsubscribed before it settled: its timeout ran out, or the caller went away.
|
|
58
|
+
return () => record(context.signal.reason);
|
|
59
|
+
});
|
|
60
|
+
const policyAt = (index) => {
|
|
61
|
+
const model = plan.chain[index];
|
|
62
|
+
const own = plan.policyFor(model);
|
|
63
|
+
const policies = [];
|
|
64
|
+
if (index < plan.chain.length - 1) {
|
|
65
|
+
policies.push(new FallbackPolicy((error, context) => {
|
|
66
|
+
// A fallback that works must not hide why the model failed: a revoked key, a refused request.
|
|
67
|
+
logger.warn(`The model "${labelOf(model)}" failed (${describeError(error)}); falling back to "${labelOf(plan.chain[index + 1])}".`);
|
|
68
|
+
return policyAt(index + 1).executeObservable(attemptsOf(index + 1), { signal: context.signal, source: plan.source });
|
|
69
|
+
}, { name: `${nameOf(model)} fallback`, handleIf: (error) => isFallbackable(error) && plan.fallbackIf(error) }));
|
|
70
|
+
}
|
|
71
|
+
if (own) {
|
|
72
|
+
policies.push(own);
|
|
73
|
+
}
|
|
74
|
+
return ResiliencePolicy.wrap(...policies);
|
|
75
|
+
};
|
|
76
|
+
// Inside a resilient entrypoint (or `ResilienceContext.run()`), its attempt's signal stops the call too.
|
|
77
|
+
const ambient = new ResilienceContext().signal;
|
|
78
|
+
const signal = ambient ? AbortSignal.any([plan.signal, ambient]) : plan.signal;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
let outcome;
|
|
81
|
+
policyAt(0)
|
|
82
|
+
.executeObservable(attemptsOf(0), { signal, source: plan.source })
|
|
83
|
+
.subscribe({
|
|
84
|
+
next: (event) => {
|
|
85
|
+
if (event.type === 'chunk') {
|
|
86
|
+
emit?.(event.chunk);
|
|
87
|
+
}
|
|
88
|
+
else if (event.type === 'outcome') {
|
|
89
|
+
outcome = { value: event.value, attempt: event.attempt };
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
error: (error) => reject(finalError(error, failures, signal)),
|
|
93
|
+
complete: () => (outcome ? resolve({ ...outcome, attempts }) : reject(finalError(new Error('The model call ended without an answer'), failures, signal))),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The model's own policy from its settings: `RetryPolicy` (honoring the provider's `retry-after`) around
|
|
99
|
+
* `TimeoutPolicy`, either one left out when it is `false`.
|
|
100
|
+
*/
|
|
101
|
+
export function modelPolicy(model, { retry, timeout }, namespace = 'ai') {
|
|
102
|
+
const policies = [];
|
|
103
|
+
if (retry !== false) {
|
|
104
|
+
policies.push(retryPolicy(model, retry ?? {}, namespace));
|
|
105
|
+
}
|
|
106
|
+
if (timeout !== false) {
|
|
107
|
+
policies.push(new TimeoutPolicy({ timeout: toMilliseconds(timeout ?? DEFAULT_TIMEOUT, 'timeout'), name: `${nameOf(model, namespace)} timeout` }));
|
|
108
|
+
}
|
|
109
|
+
return policies.length > 0 ? ResiliencePolicy.wrap(...policies) : undefined;
|
|
110
|
+
}
|
|
111
|
+
const DEFAULT_TIMEOUT = '60s';
|
|
112
|
+
const DEFAULT_BACKOFF = { delay: 1_000, factor: 2, maxDelay: 30_000 };
|
|
113
|
+
function retryPolicy(model, retry, namespace) {
|
|
114
|
+
const backoff = retry.backoff;
|
|
115
|
+
const maxDelay = typeof backoff === 'object' && backoff.maxDelay !== undefined ? toMilliseconds(backoff.maxDelay, 'retry.backoff.maxDelay') : DEFAULT_BACKOFF.maxDelay;
|
|
116
|
+
const retryIf = retry.retryIf ?? isRetryable;
|
|
117
|
+
return new RetryPolicy({
|
|
118
|
+
name: `${nameOf(model, namespace)} retry`,
|
|
119
|
+
attempts: retry.attempts ?? 3,
|
|
120
|
+
...(retry.retryOnResult && { retryOnResult: retry.retryOnResult }),
|
|
121
|
+
// A provider that asks for a longer wait than the backoff allows: on to the fallback.
|
|
122
|
+
retryIf: (error, attempt) => !(error instanceof AiOutputError) && (retryAfter(error) ?? 0) <= maxDelay && retryIf(error, attempt),
|
|
123
|
+
backoff: typeof backoff === 'function'
|
|
124
|
+
? backoff
|
|
125
|
+
: (attempt, error) => retryAfter(error) ?? exponential(attempt, { ...DEFAULT_BACKOFF, ...backoff }),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** What is retried by default: what the provider marks retryable (with network failures), and timeouts. */
|
|
129
|
+
export function isRetryable(error) {
|
|
130
|
+
if (error instanceof ResilienceTimeoutError) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return APICallError.isInstance(error) && error.isRetryable;
|
|
134
|
+
}
|
|
135
|
+
function isFallbackable(error) {
|
|
136
|
+
return !(error instanceof AiOutputError) && !(error instanceof Error && CALLER_MISTAKES.has(error.name));
|
|
137
|
+
}
|
|
138
|
+
function finalError(error, failures, signal) {
|
|
139
|
+
if (signal.aborted) {
|
|
140
|
+
return signal.reason;
|
|
141
|
+
}
|
|
142
|
+
if (error instanceof AiOutputError || error instanceof AiBudgetExceededError || error instanceof AiConfigurationError) {
|
|
143
|
+
return error;
|
|
144
|
+
}
|
|
145
|
+
// A circuit breaker's, bulkhead's or rate limit's rejection: the application's policy said no.
|
|
146
|
+
if (error instanceof ResilienceError && !(error instanceof ResilienceTimeoutError)) {
|
|
147
|
+
return error;
|
|
148
|
+
}
|
|
149
|
+
return new AiModelError(failures.length > 0 ? failures : [{ model: { provider: 'unknown', modelId: 'unknown' }, attempt: 1, error }]);
|
|
150
|
+
}
|
|
151
|
+
/** `ai` wraps errors it retried itself; with `maxRetries: 0` it should not, but the last one is what failed. */
|
|
152
|
+
function unwrap(error) {
|
|
153
|
+
return RetryError.isInstance(error) ? (error.lastError ?? error) : error;
|
|
154
|
+
}
|
|
155
|
+
function exponential(attempt, { delay, factor = 2, maxDelay, jitter = 'full' }) {
|
|
156
|
+
const max = toMilliseconds(maxDelay ?? DEFAULT_BACKOFF.maxDelay, 'retry.backoff.maxDelay');
|
|
157
|
+
const computed = Math.min(max, toMilliseconds(delay ?? DEFAULT_BACKOFF.delay, 'retry.backoff.delay') * Math.max(1, factor) ** (attempt - 1));
|
|
158
|
+
switch (jitter) {
|
|
159
|
+
case 'none':
|
|
160
|
+
return computed;
|
|
161
|
+
case 'equal':
|
|
162
|
+
return computed / 2 + Math.random() * (computed / 2);
|
|
163
|
+
default:
|
|
164
|
+
return Math.random() * computed;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** The provider's `retry-after-ms` or `retry-after`, in milliseconds. */
|
|
168
|
+
function retryAfter(error) {
|
|
169
|
+
if (!APICallError.isInstance(error) || !error.responseHeaders) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
const headers = Object.fromEntries(Object.entries(error.responseHeaders).map(([name, value]) => [name.toLowerCase(), value]));
|
|
173
|
+
const ms = Number.parseFloat(headers['retry-after-ms'] ?? '');
|
|
174
|
+
if (Number.isFinite(ms) && ms >= 0) {
|
|
175
|
+
return ms;
|
|
176
|
+
}
|
|
177
|
+
const value = headers['retry-after'];
|
|
178
|
+
if (value === undefined) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const seconds = Number.parseFloat(value);
|
|
182
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
183
|
+
return seconds * 1_000;
|
|
184
|
+
}
|
|
185
|
+
const date = Date.parse(value);
|
|
186
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
187
|
+
}
|
|
188
|
+
function nameOf(model, namespace = 'ai') {
|
|
189
|
+
return `${namespace}:${labelOf(model)}`;
|
|
190
|
+
}
|
|
191
|
+
function labelOf(model) {
|
|
192
|
+
return model.info.name ?? `${model.info.provider}:${model.info.modelId}`;
|
|
193
|
+
}
|
|
194
|
+
/** The error's class, its HTTP status if it has one, and its message: `APICallError, 401: Invalid API key`. */
|
|
195
|
+
function describeError(error) {
|
|
196
|
+
if (!(error instanceof Error)) {
|
|
197
|
+
return String(error);
|
|
198
|
+
}
|
|
199
|
+
const { statusCode, status } = error;
|
|
200
|
+
const code = typeof statusCode === 'number' ? statusCode : typeof status === 'number' ? status : undefined;
|
|
201
|
+
return `${error.constructor.name}${code !== undefined ? `, ${code}` : ''}: ${error.message}`;
|
|
202
|
+
}
|