364 lines
12 KiB
TypeScript
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 }]),
|
|
);
|
|
},
|
|
};
|
|
}
|