release: WRNexusJS 0.8.0
This commit is contained in:
+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 }]),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user