release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+43 -2
View File
@@ -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";
+353
View File
@@ -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
View File
@@ -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 }]),
);
},
};
}