Files
WRNexusJS/packages/ai/src/providers.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

364 lines
12 KiB
TypeScript

import {
AIError,
createAI,
type AI,
type AIConfig,
type GenerateOptions,
type Message,
} from "./index.ts";
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;
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[],
options?: GenerateOptions & { provider?: string },
): Promise<AIResult<string>>;
generateObject<T>(
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",
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, options) => client.stream(prompt, options),
};
}
export function aiProvider(
name: string,
client: AI,
capabilities: AIProviderCapabilities = {},
): AIProvider {
return {
name,
capabilities: { streaming: true, ...capabilities },
async generate(prompt, options) {
return {
value: await client.generate(prompt, options),
provider: name,
model: options?.model,
};
},
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;
},
};
}
function jsonText(value: string): string {
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(value);
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) {
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>(
prompt: string | Message[],
callOptions: GenerateOptions & {
provider?: string;
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 {
value = JSON.parse(jsonText(result.value));
} catch {
throw new AIError("AI response was not valid JSON", 0, "structured_output_error");
}
if (callOptions.validate && !callOptions.validate(value))
throw new AIError(
"AI response failed structured output validation",
0,
"structured_output_error",
);
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 || provider.capabilities?.streaming === false) continue;
try {
yield* provider.stream(prompt, callOptions);
return;
} catch (error) {
last = error;
if (options.fallback === false || callOptions.provider) throw error;
}
}
if (last) throw last;
const result = await generate(prompt, callOptions);
yield result.value;
},
capabilities(provider) {
return Object.fromEntries(
select(provider).map((item) => [item.name, { ...item.capabilities }]),
);
},
};
}