release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# @wrnexus/ai
|
||||
|
||||
Provider-neutral AI orchestration for OpenAI, Anthropic, Google and local OpenAI-compatible models,
|
||||
with streaming, structured output, tools, embeddings, vector search/RAG, conversation persistence,
|
||||
templates, guardrails, usage events, fallback, rate limits and evaluation reports.
|
||||
|
||||
> A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -120,6 +124,34 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-provider client
|
||||
|
||||
`createAIClient` adds named-provider selection and fallback, capability discovery,
|
||||
validated JSON output, validated tool execution, abort-aware exponential retries,
|
||||
and per-provider circuit breakers. Attempt events intentionally contain metadata
|
||||
only: prompts, credentials, and raw model responses are never passed to telemetry.
|
||||
|
||||
```ts
|
||||
import { anthropicProvider, createAIClient } from "@wrnexus/ai";
|
||||
|
||||
const ai = createAIClient({
|
||||
providers: [anthropicProvider()],
|
||||
retry: { attempts: 3, baseDelayMs: 100, maxDelayMs: 2_000 },
|
||||
circuitBreaker: { failureThreshold: 5, resetAfterMs: 30_000 },
|
||||
});
|
||||
|
||||
const result = await ai.generateObject<{ title: string }>("Return a JSON title", {
|
||||
validate: (value): value is { title: string } =>
|
||||
typeof value === "object" && value !== null && "title" in value,
|
||||
});
|
||||
```
|
||||
|
||||
Providers can return normalized `usage` (`inputTokens`, `outputTokens`,
|
||||
`totalTokens`, and `costUsd`) and `toolCalls`. Use `executeTools` with a named,
|
||||
validated tool registry; unknown tools and invalid arguments are rejected before
|
||||
application code runs. `deterministicAIProvider` supplies ordered or computed
|
||||
offline responses for tests and examples without API keys or network calls.
|
||||
|
||||
## Usage
|
||||
|
||||
### Return generated JSON from an API route
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./platform": "./src/platform.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,5 +245,46 @@ export function createAI(config: AIConfig = {}): AI {
|
||||
|
||||
return { generate, stream, streamResponse };
|
||||
}
|
||||
export { anthropicProvider, aiProvider, createAIClient } from "./providers.ts";
|
||||
export type { AIUsage, AIResult, AIProvider, AIClient, AIClientOptions } from "./providers.ts";
|
||||
export {
|
||||
anthropicProvider,
|
||||
aiProvider,
|
||||
createAIClient,
|
||||
deterministicAIProvider,
|
||||
} from "./providers.ts";
|
||||
export type {
|
||||
AIAttemptEvent,
|
||||
AICircuitBreakerOptions,
|
||||
AIClient,
|
||||
AIClientOptions,
|
||||
AIProvider,
|
||||
AIProviderCapabilities,
|
||||
AIResult,
|
||||
AIRetryOptions,
|
||||
AITool,
|
||||
AIToolCall,
|
||||
AIUsage,
|
||||
DeterministicAIProviderOptions,
|
||||
} from "./providers.ts";
|
||||
export {
|
||||
aiRateLimiter,
|
||||
createRagPipeline,
|
||||
evaluateAI,
|
||||
googleAIProvider,
|
||||
guardedProvider,
|
||||
localAIProvider,
|
||||
maxPromptLength,
|
||||
memoryConversationStore,
|
||||
memoryVectorStore,
|
||||
openAIEmbeddings,
|
||||
openAIProvider,
|
||||
promptTemplate,
|
||||
} from "./platform.ts";
|
||||
export type {
|
||||
AIGuardrail,
|
||||
ConversationStore,
|
||||
EmbeddingProvider,
|
||||
HttpAIProviderOptions,
|
||||
VectorMatch,
|
||||
VectorRecord,
|
||||
VectorStore,
|
||||
} from "./platform.ts";
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { AIError, type GenerateOptions, type Message } from "./index.ts";
|
||||
import type { AIProvider, AIResult } from "./providers.ts";
|
||||
|
||||
export interface HttpAIProviderOptions {
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
fetch?: typeof fetch;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
function key(configured: string | undefined, name: string): string {
|
||||
const value = configured ?? process.env[name];
|
||||
if (!value) throw new AIError(`Missing ${name}.`, 0, "authentication_error");
|
||||
return value;
|
||||
}
|
||||
function promptMessages(prompt: string | Message[], options?: GenerateOptions) {
|
||||
const messages =
|
||||
options?.messages ??
|
||||
(typeof prompt === "string" ? [{ role: "user" as const, content: prompt }] : prompt);
|
||||
return options?.system ? [{ role: "system", content: options.system }, ...messages] : messages;
|
||||
}
|
||||
|
||||
export function openAIProvider(options: HttpAIProviderOptions): AIProvider {
|
||||
const send = options.fetch ?? fetch;
|
||||
const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
|
||||
return {
|
||||
name: "openai",
|
||||
capabilities: { streaming: false, structuredOutput: true, tools: true, usage: true },
|
||||
async generate(prompt, call = {}) {
|
||||
const response = await send(`${base}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${key(options.apiKey, "OPENAI_API_KEY")}`,
|
||||
"content-type": "application/json",
|
||||
...Object.fromEntries(new Headers(options.headers)),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: call.model ?? options.model,
|
||||
messages: promptMessages(prompt, call),
|
||||
...(call.maxTokens ? { max_completion_tokens: call.maxTokens } : {}),
|
||||
}),
|
||||
signal: call.signal,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new AIError(`OpenAI returned ${response.status}.`, response.status, "provider_error");
|
||||
const body = (await response.json()) as any;
|
||||
return {
|
||||
value: String(body.choices?.[0]?.message?.content ?? ""),
|
||||
provider: "openai",
|
||||
model: body.model,
|
||||
finishReason: body.choices?.[0]?.finish_reason,
|
||||
usage: {
|
||||
inputTokens: body.usage?.prompt_tokens,
|
||||
outputTokens: body.usage?.completion_tokens,
|
||||
totalTokens: body.usage?.total_tokens,
|
||||
},
|
||||
toolCalls: body.choices?.[0]?.message?.tool_calls?.map((tool: any) => ({
|
||||
id: String(tool.id),
|
||||
name: String(tool.function?.name),
|
||||
arguments: JSON.parse(tool.function?.arguments ?? "{}"),
|
||||
})),
|
||||
raw: body,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function googleAIProvider(options: HttpAIProviderOptions): AIProvider {
|
||||
const send = options.fetch ?? fetch;
|
||||
const base = (options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
return {
|
||||
name: "google",
|
||||
capabilities: { streaming: false, structuredOutput: true, tools: true, usage: true },
|
||||
async generate(prompt, call = {}) {
|
||||
const response = await send(
|
||||
`${base}/models/${encodeURIComponent(call.model ?? options.model)}:generateContent?key=${encodeURIComponent(key(options.apiKey, "GOOGLE_AI_API_KEY"))}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...Object.fromEntries(new Headers(options.headers)),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: promptMessages(prompt, call)
|
||||
.filter((message) => message.role !== "system")
|
||||
.map((message) => ({
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: message.content }],
|
||||
})),
|
||||
...(call.system ? { systemInstruction: { parts: [{ text: call.system }] } } : {}),
|
||||
}),
|
||||
signal: call.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new AIError(
|
||||
`Google AI returned ${response.status}.`,
|
||||
response.status,
|
||||
"provider_error",
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
return {
|
||||
value: String(
|
||||
body.candidates?.[0]?.content?.parts?.map((part: any) => part.text ?? "").join("") ?? "",
|
||||
),
|
||||
provider: "google",
|
||||
model: call.model ?? options.model,
|
||||
finishReason: body.candidates?.[0]?.finishReason,
|
||||
usage: {
|
||||
inputTokens: body.usageMetadata?.promptTokenCount,
|
||||
outputTokens: body.usageMetadata?.candidatesTokenCount,
|
||||
totalTokens: body.usageMetadata?.totalTokenCount,
|
||||
},
|
||||
raw: body,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** OpenAI-compatible local servers including Ollama, llama.cpp and vLLM. */
|
||||
export function localAIProvider(
|
||||
options: Omit<HttpAIProviderOptions, "apiKey"> & { apiKey?: string },
|
||||
): AIProvider {
|
||||
const provider = openAIProvider({
|
||||
...options,
|
||||
apiKey: options.apiKey ?? "local",
|
||||
baseUrl: options.baseUrl ?? "http://localhost:11434/v1",
|
||||
});
|
||||
return {
|
||||
...provider,
|
||||
name: "local",
|
||||
generate: async (prompt, call) => ({
|
||||
...(await provider.generate(prompt, call)),
|
||||
provider: "local",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmbeddingProvider {
|
||||
name: string;
|
||||
embed(
|
||||
values: string[],
|
||||
options?: { model?: string; signal?: AbortSignal },
|
||||
): Promise<{ vectors: number[][]; usage?: { tokens?: number } }>;
|
||||
}
|
||||
export function openAIEmbeddings(options: HttpAIProviderOptions): EmbeddingProvider {
|
||||
return {
|
||||
name: "openai",
|
||||
async embed(values, call = {}) {
|
||||
const response = await (options.fetch ?? fetch)(
|
||||
`${(options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "")}/embeddings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${key(options.apiKey, "OPENAI_API_KEY")}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model: call.model ?? options.model, input: values }),
|
||||
signal: call.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new AIError(`Embedding provider returned ${response.status}.`, response.status);
|
||||
const body = (await response.json()) as any;
|
||||
return {
|
||||
vectors: body.data.map((item: any) => item.embedding as number[]),
|
||||
usage: { tokens: body.usage?.total_tokens },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface VectorRecord<T = Record<string, unknown>> {
|
||||
id: string;
|
||||
vector: number[];
|
||||
text: string;
|
||||
metadata: T;
|
||||
}
|
||||
export interface VectorMatch<T = Record<string, unknown>> extends VectorRecord<T> {
|
||||
score: number;
|
||||
}
|
||||
export interface VectorStore<T = Record<string, unknown>> {
|
||||
upsert(records: VectorRecord<T>[]): Promise<void>;
|
||||
query(
|
||||
vector: number[],
|
||||
limit?: number,
|
||||
filter?: (metadata: T) => boolean,
|
||||
): Promise<VectorMatch<T>[]>;
|
||||
delete(ids: string[]): Promise<void>;
|
||||
}
|
||||
export function memoryVectorStore<T = Record<string, unknown>>(): VectorStore<T> {
|
||||
const records = new Map<string, VectorRecord<T>>();
|
||||
const cosine = (a: number[], b: number[]) => {
|
||||
if (a.length !== b.length || !a.length) return 0;
|
||||
let dot = 0,
|
||||
aa = 0,
|
||||
bb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i]! * b[i]!;
|
||||
aa += a[i]! ** 2;
|
||||
bb += b[i]! ** 2;
|
||||
}
|
||||
return aa && bb ? dot / Math.sqrt(aa * bb) : 0;
|
||||
};
|
||||
return {
|
||||
async upsert(values) {
|
||||
for (const value of values) records.set(value.id, { ...value, vector: [...value.vector] });
|
||||
},
|
||||
async query(vector, limit = 5, filter) {
|
||||
return [...records.values()]
|
||||
.filter((record) => !filter || filter(record.metadata))
|
||||
.map((record) => ({ ...record, score: cosine(vector, record.vector) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, Math.max(0, limit));
|
||||
},
|
||||
async delete(ids) {
|
||||
for (const id of ids) records.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createRagPipeline<T>(options: {
|
||||
embeddings: EmbeddingProvider;
|
||||
store: VectorStore<T>;
|
||||
generate: (prompt: string, options?: GenerateOptions) => Promise<AIResult<string>>;
|
||||
maxContextCharacters?: number;
|
||||
}) {
|
||||
return {
|
||||
async index(documents: Array<{ id: string; text: string; metadata: T }>) {
|
||||
const embedded = await options.embeddings.embed(documents.map((document) => document.text));
|
||||
await options.store.upsert(
|
||||
documents.map((document, index) => ({ ...document, vector: embedded.vectors[index]! })),
|
||||
);
|
||||
return documents.length;
|
||||
},
|
||||
async ask(
|
||||
question: string,
|
||||
call: GenerateOptions & { limit?: number; filter?: (metadata: T) => boolean } = {},
|
||||
) {
|
||||
const embedded = await options.embeddings.embed([question], { signal: call.signal });
|
||||
const matches = await options.store.query(embedded.vectors[0]!, call.limit, call.filter);
|
||||
const context = matches
|
||||
.map((match, index) => `[${index + 1}] ${match.text}`)
|
||||
.join("\n\n")
|
||||
.slice(0, options.maxContextCharacters ?? 12_000);
|
||||
const result = await options.generate(
|
||||
`Answer using only the supplied context. Cite sources as [n].\n\nContext:\n${context}\n\nQuestion: ${question}`,
|
||||
call,
|
||||
);
|
||||
return { ...result, sources: matches };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversationStore {
|
||||
load(id: string): Promise<Message[]>;
|
||||
append(id: string, messages: Message[]): Promise<void>;
|
||||
clear(id: string): Promise<void>;
|
||||
}
|
||||
export function memoryConversationStore(maxMessages = 100): ConversationStore {
|
||||
const conversations = new Map<string, Message[]>();
|
||||
return {
|
||||
async load(id) {
|
||||
return [...(conversations.get(id) ?? [])];
|
||||
},
|
||||
async append(id, messages) {
|
||||
conversations.set(id, [...(conversations.get(id) ?? []), ...messages].slice(-maxMessages));
|
||||
},
|
||||
async clear(id) {
|
||||
conversations.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
export function promptTemplate(template: string) {
|
||||
return (variables: Record<string, string | number>) =>
|
||||
template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_match, name: string) => {
|
||||
if (!(name in variables)) throw new Error(`WRN-AI-PROMPT-VARIABLE:${name}`);
|
||||
return String(variables[name]);
|
||||
});
|
||||
}
|
||||
export type AIGuardrail = (input: {
|
||||
prompt: string | Message[];
|
||||
output?: string;
|
||||
}) => void | Promise<void>;
|
||||
export const maxPromptLength =
|
||||
(maximum: number): AIGuardrail =>
|
||||
({ prompt }) => {
|
||||
const length =
|
||||
typeof prompt === "string"
|
||||
? prompt.length
|
||||
: prompt.reduce((sum, message) => sum + message.content.length, 0);
|
||||
if (length > maximum)
|
||||
throw new AIError("Prompt exceeds configured guardrail.", 400, "guardrail");
|
||||
};
|
||||
export function guardedProvider(provider: AIProvider, guardrails: AIGuardrail[]): AIProvider {
|
||||
return {
|
||||
...provider,
|
||||
async generate(prompt, options) {
|
||||
for (const guardrail of guardrails) await guardrail({ prompt });
|
||||
const result = await provider.generate(prompt, options);
|
||||
for (const guardrail of guardrails) await guardrail({ prompt, output: result.value });
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
export function aiRateLimiter(options: { limit: number; windowMs: number; now?: () => number }) {
|
||||
const buckets = new Map<string, { count: number; reset: number }>();
|
||||
const now = options.now ?? Date.now;
|
||||
return (key: string) => {
|
||||
const time = now();
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket || bucket.reset <= time) {
|
||||
buckets.set(key, { count: 1, reset: time + options.windowMs });
|
||||
return { allowed: true, remaining: options.limit - 1 };
|
||||
}
|
||||
if (bucket.count >= options.limit)
|
||||
return { allowed: false, remaining: 0, retryAfterMs: bucket.reset - time };
|
||||
bucket.count++;
|
||||
return { allowed: true, remaining: options.limit - bucket.count };
|
||||
};
|
||||
}
|
||||
export async function evaluateAI(
|
||||
cases: Array<{
|
||||
name: string;
|
||||
prompt: string;
|
||||
expected?: string;
|
||||
score?: (output: string) => number | Promise<number>;
|
||||
}>,
|
||||
generate: (prompt: string) => Promise<string>,
|
||||
) {
|
||||
const results = [];
|
||||
for (const item of cases) {
|
||||
const started = performance.now();
|
||||
const output = await generate(item.prompt);
|
||||
const score = item.score
|
||||
? await item.score(output)
|
||||
: item.expected === undefined
|
||||
? 1
|
||||
: output.includes(item.expected)
|
||||
? 1
|
||||
: 0;
|
||||
results.push({ name: item.name, output, score, durationMs: performance.now() - started });
|
||||
}
|
||||
return {
|
||||
results,
|
||||
score: results.length
|
||||
? results.reduce((sum, result) => sum + result.score, 0) / results.length
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
+229
-16
@@ -11,28 +11,79 @@ export interface AIUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
export interface AIResult<T = string> {
|
||||
value: T;
|
||||
provider: string;
|
||||
model?: string;
|
||||
usage?: AIUsage;
|
||||
finishReason?: string;
|
||||
toolCalls?: AIToolCall[];
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface AIProviderCapabilities {
|
||||
streaming?: boolean;
|
||||
structuredOutput?: boolean;
|
||||
tools?: boolean;
|
||||
usage?: boolean;
|
||||
}
|
||||
|
||||
export interface AIToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
}
|
||||
|
||||
export interface AITool<TInput = unknown, TOutput = unknown> {
|
||||
name: string;
|
||||
description?: string;
|
||||
validate?: (value: unknown) => value is TInput;
|
||||
execute(input: TInput, context: { signal?: AbortSignal }): TOutput | Promise<TOutput>;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
name: string;
|
||||
capabilities?: AIProviderCapabilities;
|
||||
generate(prompt: string | Message[], options?: GenerateOptions): Promise<AIResult<string>>;
|
||||
stream?(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions,
|
||||
): AsyncGenerator<string, void, unknown>;
|
||||
}
|
||||
|
||||
export interface AIRetryOptions {
|
||||
attempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
retry?: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
export interface AICircuitBreakerOptions {
|
||||
failureThreshold?: number;
|
||||
resetAfterMs?: number;
|
||||
}
|
||||
|
||||
export interface AIClientOptions {
|
||||
providers: AIProvider[];
|
||||
fallback?: boolean;
|
||||
onAttempt?: (provider: string, error?: unknown) => void | Promise<void>;
|
||||
retry?: AIRetryOptions;
|
||||
circuitBreaker?: AICircuitBreakerOptions;
|
||||
/** Receives metadata only; prompts, provider raw responses, and credentials are never emitted. */
|
||||
onAttempt?: (event: AIAttemptEvent) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AIAttemptEvent {
|
||||
provider: string;
|
||||
attempt: number;
|
||||
outcome: "start" | "success" | "error" | "circuit-open";
|
||||
durationMs?: number;
|
||||
error?: { name: string; type?: string; status?: number; message: string };
|
||||
usage?: AIUsage;
|
||||
}
|
||||
|
||||
export interface AIClient {
|
||||
generate(
|
||||
prompt: string | Message[],
|
||||
@@ -42,40 +93,82 @@ export interface AIClient {
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string; validate?: (value: unknown) => value is T },
|
||||
): Promise<AIResult<T>>;
|
||||
executeTools(
|
||||
result: AIResult,
|
||||
tools: AITool[],
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<Array<{ call: AIToolCall; value: unknown }>>;
|
||||
stream(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string },
|
||||
): AsyncGenerator<string, void, unknown>;
|
||||
capabilities(provider?: string): Record<string, AIProviderCapabilities>;
|
||||
}
|
||||
|
||||
export function anthropicProvider(config: AIConfig = {}): AIProvider {
|
||||
const client = createAI(config);
|
||||
return {
|
||||
name: "anthropic",
|
||||
async generate(prompt: string | Message[], options?: GenerateOptions) {
|
||||
capabilities: { streaming: true, structuredOutput: true },
|
||||
async generate(prompt, options) {
|
||||
return {
|
||||
value: await client.generate(prompt, options),
|
||||
provider: "anthropic",
|
||||
model: options?.model ?? config.model,
|
||||
};
|
||||
},
|
||||
stream: (prompt: string | Message[], options?: GenerateOptions) =>
|
||||
client.stream(prompt, options),
|
||||
stream: (prompt, options) => client.stream(prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function aiProvider(name: string, client: AI): AIProvider {
|
||||
export function aiProvider(
|
||||
name: string,
|
||||
client: AI,
|
||||
capabilities: AIProviderCapabilities = {},
|
||||
): AIProvider {
|
||||
return {
|
||||
name,
|
||||
async generate(prompt: string | Message[], options?: GenerateOptions) {
|
||||
capabilities: { streaming: true, ...capabilities },
|
||||
async generate(prompt, options) {
|
||||
return {
|
||||
value: await client.generate(prompt, options),
|
||||
provider: name,
|
||||
model: options?.model,
|
||||
};
|
||||
},
|
||||
stream: (prompt: string | Message[], options?: GenerateOptions) =>
|
||||
client.stream(prompt, options),
|
||||
stream: (prompt, options) => client.stream(prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeterministicAIProviderOptions {
|
||||
name?: string;
|
||||
responses?: Array<string | AIResult<string>>;
|
||||
handler?: (
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions,
|
||||
) => string | AIResult<string> | Promise<string | AIResult<string>>;
|
||||
}
|
||||
|
||||
/** Offline provider for examples and tests. Responses are consumed in order. */
|
||||
export function deterministicAIProvider(options: DeterministicAIProviderOptions = {}): AIProvider {
|
||||
let index = 0;
|
||||
const name = options.name ?? "deterministic";
|
||||
return {
|
||||
name,
|
||||
capabilities: { streaming: true, structuredOutput: true, tools: true, usage: true },
|
||||
async generate(prompt, callOptions) {
|
||||
const selected = options.handler
|
||||
? await options.handler(prompt, callOptions)
|
||||
: options.responses?.[index++];
|
||||
if (selected === undefined)
|
||||
throw new AIError("No deterministic response configured", 0, "provider_error");
|
||||
return typeof selected === "string"
|
||||
? { value: selected, provider: name }
|
||||
: { ...selected, provider: name };
|
||||
},
|
||||
async *stream(prompt, callOptions) {
|
||||
yield (await this.generate(prompt, callOptions)).value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,27 +177,116 @@ function jsonText(value: string): string {
|
||||
return (fenced?.[1] ?? value).trim();
|
||||
}
|
||||
|
||||
function safeError(error: unknown): AIAttemptEvent["error"] {
|
||||
if (error instanceof AIError)
|
||||
return {
|
||||
name: error.name,
|
||||
type: error.type,
|
||||
status: error.status,
|
||||
message: error.message.slice(0, 300),
|
||||
};
|
||||
if (error instanceof Error) return { name: error.name, message: error.message.slice(0, 300) };
|
||||
return { name: "Error", message: "Unknown provider error" };
|
||||
}
|
||||
|
||||
function defaultRetry(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof AIError &&
|
||||
(error.status === 408 || error.status === 429 || error.status >= 500)
|
||||
);
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
return new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) throw signal.reason ?? abortError();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason ?? abortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createAIClient(options: AIClientOptions): AIClient {
|
||||
if (!options.providers.length) throw new Error("WRN-AI-NO-PROVIDERS");
|
||||
const duplicate = options.providers.find(
|
||||
(provider, index) =>
|
||||
options.providers.findIndex((other) => other.name === provider.name) !== index,
|
||||
);
|
||||
if (duplicate) throw new Error(`WRN-AI-DUPLICATE-PROVIDER:${duplicate.name}`);
|
||||
const circuits = new Map<string, { failures: number; openedAt?: number }>();
|
||||
const select = (name?: string) =>
|
||||
name ? options.providers.filter((provider) => provider.name === name) : options.providers;
|
||||
const attempts = Math.max(1, options.retry?.attempts ?? 1);
|
||||
const failureThreshold = Math.max(1, options.circuitBreaker?.failureThreshold ?? 5);
|
||||
const resetAfterMs = Math.max(0, options.circuitBreaker?.resetAfterMs ?? 30_000);
|
||||
|
||||
const generate: AIClient["generate"] = async (prompt, callOptions = {}) => {
|
||||
const providers = select(callOptions.provider);
|
||||
if (!providers.length)
|
||||
throw new AIError(`Unknown AI provider: ${callOptions.provider}`, 0, "provider_error");
|
||||
let last: unknown;
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
await options.onAttempt?.(provider.name);
|
||||
return await provider.generate(prompt, callOptions);
|
||||
} catch (error) {
|
||||
last = error;
|
||||
await options.onAttempt?.(provider.name, error);
|
||||
if (options.fallback === false || callOptions.provider) throw error;
|
||||
const circuit = circuits.get(provider.name) ?? { failures: 0 };
|
||||
if (circuit.openedAt !== undefined && Date.now() - circuit.openedAt < resetAfterMs) {
|
||||
await options.onAttempt?.({ provider: provider.name, attempt: 0, outcome: "circuit-open" });
|
||||
last = new AIError(`Circuit is open for provider: ${provider.name}`, 0, "circuit_open");
|
||||
if (callOptions.provider) throw last;
|
||||
continue;
|
||||
}
|
||||
if (circuit.openedAt !== undefined) {
|
||||
circuit.failures = 0;
|
||||
circuit.openedAt = undefined;
|
||||
}
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
if (callOptions.signal?.aborted) throw callOptions.signal.reason ?? abortError();
|
||||
const started = Date.now();
|
||||
await options.onAttempt?.({ provider: provider.name, attempt, outcome: "start" });
|
||||
try {
|
||||
const result = await provider.generate(prompt, callOptions);
|
||||
circuits.set(provider.name, { failures: 0 });
|
||||
await options.onAttempt?.({
|
||||
provider: provider.name,
|
||||
attempt,
|
||||
outcome: "success",
|
||||
durationMs: Date.now() - started,
|
||||
usage: result.usage,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
last = error;
|
||||
circuit.failures++;
|
||||
if (circuit.failures >= failureThreshold) circuit.openedAt = Date.now();
|
||||
circuits.set(provider.name, circuit);
|
||||
await options.onAttempt?.({
|
||||
provider: provider.name,
|
||||
attempt,
|
||||
outcome: "error",
|
||||
durationMs: Date.now() - started,
|
||||
error: safeError(error),
|
||||
});
|
||||
const retryable = (options.retry?.retry ?? defaultRetry)(error);
|
||||
if (!retryable || attempt === attempts || callOptions.signal?.aborted) break;
|
||||
const backoff = Math.min(
|
||||
options.retry?.maxDelayMs ?? 5_000,
|
||||
(options.retry?.baseDelayMs ?? 100) * 2 ** (attempt - 1),
|
||||
);
|
||||
await delay(backoff, callOptions.signal);
|
||||
}
|
||||
}
|
||||
if (options.fallback === false || callOptions.provider) throw last;
|
||||
}
|
||||
throw last;
|
||||
};
|
||||
|
||||
return {
|
||||
generate,
|
||||
async generateObject<T>(
|
||||
@@ -114,6 +296,16 @@ export function createAIClient(options: AIClientOptions): AIClient {
|
||||
validate?: (value: unknown) => value is T;
|
||||
} = {},
|
||||
) {
|
||||
const candidates = select(callOptions.provider);
|
||||
if (
|
||||
candidates.length &&
|
||||
candidates.every((provider) => provider.capabilities?.structuredOutput === false)
|
||||
)
|
||||
throw new AIError(
|
||||
"Selected provider does not support structured output",
|
||||
0,
|
||||
"capability_error",
|
||||
);
|
||||
const result = await generate(prompt, callOptions);
|
||||
let value: unknown;
|
||||
try {
|
||||
@@ -129,11 +321,27 @@ export function createAIClient(options: AIClientOptions): AIClient {
|
||||
);
|
||||
return { ...result, value: value as T };
|
||||
},
|
||||
async executeTools(result, tools, toolOptions = {}) {
|
||||
const registry = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
const output: Array<{ call: AIToolCall; value: unknown }> = [];
|
||||
for (const call of result.toolCalls ?? []) {
|
||||
if (toolOptions.signal?.aborted) throw toolOptions.signal.reason ?? abortError();
|
||||
const tool = registry.get(call.name);
|
||||
if (!tool) throw new AIError(`Unknown AI tool: ${call.name}`, 0, "tool_error");
|
||||
if (tool.validate && !tool.validate(call.arguments))
|
||||
throw new AIError(`Invalid arguments for AI tool: ${call.name}`, 0, "tool_error");
|
||||
output.push({
|
||||
call,
|
||||
value: await tool.execute(call.arguments, { signal: toolOptions.signal }),
|
||||
});
|
||||
}
|
||||
return output;
|
||||
},
|
||||
async *stream(prompt, callOptions = {}) {
|
||||
const providers = select(callOptions.provider);
|
||||
let last: unknown;
|
||||
for (const provider of providers) {
|
||||
if (!provider.stream) continue;
|
||||
if (!provider.stream || provider.capabilities?.streaming === false) continue;
|
||||
try {
|
||||
yield* provider.stream(prompt, callOptions);
|
||||
return;
|
||||
@@ -146,5 +354,10 @@ export function createAIClient(options: AIClientOptions): AIClient {
|
||||
const result = await generate(prompt, callOptions);
|
||||
yield result.value;
|
||||
},
|
||||
capabilities(provider) {
|
||||
return Object.fromEntries(
|
||||
select(provider).map((item) => [item.name, { ...item.capabilities }]),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { AIError, createAI } from "../src/index.ts";
|
||||
import {
|
||||
AIError,
|
||||
createAI,
|
||||
createAIClient,
|
||||
deterministicAIProvider,
|
||||
type AIProvider,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
@@ -54,3 +60,76 @@ test("streams fragmented SSE and keeps a final event without a newline", async (
|
||||
for await (const chunk of createAI({ apiKey: "secret" }).stream("Hi")) chunks.push(chunk);
|
||||
expect(chunks).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
test("generates and validates structured output with an offline provider", async () => {
|
||||
const client = createAIClient({
|
||||
providers: [deterministicAIProvider({ responses: ['```json\n{"ok":true}\n```'] })],
|
||||
});
|
||||
const result = await client.generateObject<{ ok: boolean }>("ignored", {
|
||||
validate: (value): value is { ok: boolean } =>
|
||||
typeof value === "object" && value !== null && "ok" in value,
|
||||
});
|
||||
expect(result.value).toEqual({ ok: true });
|
||||
expect(client.capabilities().deterministic?.structuredOutput).toBe(true);
|
||||
});
|
||||
|
||||
test("retries transient failures and reports redacted metadata", async () => {
|
||||
let calls = 0;
|
||||
const events: unknown[] = [];
|
||||
const provider: AIProvider = {
|
||||
name: "retrying",
|
||||
async generate() {
|
||||
calls++;
|
||||
if (calls === 1) throw new AIError("temporary secret-token", 503, "overloaded");
|
||||
return { value: "done", provider: "retrying", usage: { totalTokens: 3, costUsd: 0.01 } };
|
||||
},
|
||||
};
|
||||
const result = await createAIClient({
|
||||
providers: [provider],
|
||||
retry: { attempts: 2, baseDelayMs: 0 },
|
||||
onAttempt: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
}).generate("private prompt");
|
||||
expect(result.value).toBe("done");
|
||||
expect(calls).toBe(2);
|
||||
expect(JSON.stringify(events)).not.toContain("private prompt");
|
||||
expect(JSON.stringify(events)).not.toContain("raw");
|
||||
});
|
||||
|
||||
test("opens a provider circuit and falls back", async () => {
|
||||
let failedCalls = 0;
|
||||
const failing: AIProvider = {
|
||||
name: "failing",
|
||||
async generate() {
|
||||
failedCalls++;
|
||||
throw new AIError("down", 503);
|
||||
},
|
||||
};
|
||||
const client = createAIClient({
|
||||
providers: [failing, deterministicAIProvider({ responses: ["one", "two"] })],
|
||||
retry: { attempts: 1 },
|
||||
circuitBreaker: { failureThreshold: 1, resetAfterMs: 60_000 },
|
||||
});
|
||||
expect((await client.generate("first")).value).toBe("one");
|
||||
expect((await client.generate("second")).value).toBe("two");
|
||||
expect(failedCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("executes validated tool calls and forwards cancellation", async () => {
|
||||
const client = createAIClient({ providers: [deterministicAIProvider({ responses: ["ok"] })] });
|
||||
const result = {
|
||||
value: "",
|
||||
provider: "deterministic",
|
||||
toolCalls: [{ id: "1", name: "double", arguments: { value: 4 } }],
|
||||
};
|
||||
const output = await client.executeTools(result, [
|
||||
{
|
||||
name: "double",
|
||||
validate: (value): value is { value: number } =>
|
||||
typeof value === "object" && value !== null && "value" in value,
|
||||
execute: ({ value }) => value * 2,
|
||||
},
|
||||
]);
|
||||
expect(output[0]?.value).toBe(8);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
aiRateLimiter,
|
||||
createRagPipeline,
|
||||
evaluateAI,
|
||||
googleAIProvider,
|
||||
guardedProvider,
|
||||
localAIProvider,
|
||||
maxPromptLength,
|
||||
memoryConversationStore,
|
||||
memoryVectorStore,
|
||||
openAIProvider,
|
||||
promptTemplate,
|
||||
} from "../src/platform.ts";
|
||||
|
||||
describe("AI platform", () => {
|
||||
test("adapts OpenAI, Google and local providers", async () => {
|
||||
const openFetch = (async () =>
|
||||
Response.json({
|
||||
model: "gpt",
|
||||
choices: [{ message: { content: "hello" }, finish_reason: "stop" }],
|
||||
usage: { total_tokens: 3 },
|
||||
})) as unknown as typeof fetch;
|
||||
expect(
|
||||
(await openAIProvider({ model: "gpt", apiKey: "key", fetch: openFetch }).generate("hi"))
|
||||
.value,
|
||||
).toBe("hello");
|
||||
const googleFetch = (async () =>
|
||||
Response.json({
|
||||
candidates: [{ content: { parts: [{ text: "hola" }] } }],
|
||||
usageMetadata: { totalTokenCount: 2 },
|
||||
})) as unknown as typeof fetch;
|
||||
expect(
|
||||
(
|
||||
await googleAIProvider({ model: "gemini", apiKey: "key", fetch: googleFetch }).generate(
|
||||
"hi",
|
||||
)
|
||||
).value,
|
||||
).toBe("hola");
|
||||
expect(
|
||||
(await localAIProvider({ model: "llama", fetch: openFetch }).generate("hi")).provider,
|
||||
).toBe("local");
|
||||
});
|
||||
|
||||
test("indexes and retrieves a RAG answer", async () => {
|
||||
const store = memoryVectorStore<{ source: string }>();
|
||||
const embeddings = {
|
||||
name: "test",
|
||||
embed: async (values: string[]) => ({
|
||||
vectors: values.map((value) => (value.includes("Bun") ? [1, 0] : [0, 1])),
|
||||
}),
|
||||
};
|
||||
const rag = createRagPipeline({
|
||||
embeddings,
|
||||
store,
|
||||
generate: async (prompt) => ({
|
||||
value: prompt.includes("fast") ? "Bun [1]" : "none",
|
||||
provider: "test",
|
||||
}),
|
||||
});
|
||||
await rag.index([
|
||||
{ id: "bun", text: "Bun is fast", metadata: { source: "docs" } },
|
||||
{ id: "other", text: "Other", metadata: { source: "other" } },
|
||||
]);
|
||||
const result = await rag.ask("Bun runtime");
|
||||
expect(result.value).toBe("Bun [1]");
|
||||
expect(result.sources[0]?.id).toBe("bun");
|
||||
});
|
||||
|
||||
test("persists conversations, renders prompts, guards and rate limits", async () => {
|
||||
const conversations = memoryConversationStore(2);
|
||||
await conversations.append("one", [
|
||||
{ role: "user", content: "a" },
|
||||
{ role: "assistant", content: "b" },
|
||||
{ role: "user", content: "c" },
|
||||
]);
|
||||
expect(await conversations.load("one")).toHaveLength(2);
|
||||
expect(promptTemplate("Hello {{name}}")({ name: "Wire" })).toBe("Hello Wire");
|
||||
const provider = guardedProvider(
|
||||
{ name: "test", generate: async () => ({ value: "ok", provider: "test" }) },
|
||||
[maxPromptLength(3)],
|
||||
);
|
||||
await expect(provider.generate("long")).rejects.toThrow("guardrail");
|
||||
const limit = aiRateLimiter({ limit: 1, windowMs: 100, now: () => 0 });
|
||||
expect(limit("u").allowed).toBe(true);
|
||||
expect(limit("u").allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("evaluates model output", async () => {
|
||||
const report = await evaluateAI(
|
||||
[{ name: "answer", prompt: "question", expected: "42" }],
|
||||
async () => "42",
|
||||
);
|
||||
expect(report.score).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user