/** * @wrnexus/ai — a tiny, zero-dependency Claude (Anthropic) client for WrNexus apps. * * Use it in API routes, jobs, or anywhere server-side to generate text with Claude. * It talks to the Anthropic Messages API over `fetch` (no SDK dependency, Bun-native), * and defaults to the most capable model, `claude-opus-4-8`. * * import { createAI } from "@wrnexus/ai"; * const ai = createAI(); // reads ANTHROPIC_API_KEY * const text = await ai.generate("Write a haiku about Bun."); * * Streaming (great for API routes): * export const POST = async (ctx) => ai.streamResponse(await ctx.req.text()); */ export type Role = "user" | "assistant"; export interface Message { role: Role; content: string; } /** Reasoning effort — higher means deeper thinking + more tokens. */ export type Effort = "low" | "medium" | "high" | "xhigh" | "max"; export interface AIConfig { /** Anthropic API key. Default: `ANTHROPIC_API_KEY` from the environment. */ apiKey?: string; /** Model id. Default: `claude-opus-4-8` (the most capable Claude model). */ model?: string; /** Default max output tokens. Default: 4096. */ maxTokens?: number; /** API base URL. Default: `https://api.anthropic.com`. */ baseURL?: string; /** `anthropic-version` header. Default: `2023-06-01`. */ version?: string; } export interface GenerateOptions { /** System prompt — sets the assistant's role/behavior. */ system?: string; /** Override the model for this call. */ model?: string; /** Override max output tokens for this call. */ maxTokens?: number; /** Enable adaptive extended thinking (slower, deeper reasoning). */ thinking?: boolean; /** Reasoning effort / token spend (`output_config.effort`). */ effort?: Effort; /** Full message history — supersedes the `prompt` argument when provided. */ messages?: Message[]; /** Abort the request. */ signal?: AbortSignal; } /** Thrown when the API returns a non-2xx response or refuses the request. */ export class AIError extends Error { readonly status: number; readonly type: string; constructor(message: string, status = 0, type = "api_error") { super(message); this.name = "AIError"; this.status = status; this.type = type; } } export interface AI { /** Generate a full text response (non-streaming). */ generate(prompt: string | Message[], opts?: GenerateOptions): Promise; /** Stream the response as text deltas, as they arrive. */ stream(prompt: string | Message[], opts?: GenerateOptions): AsyncGenerator; /** Stream straight to a `Response` (text/plain) — drop-in for an API route return. */ streamResponse(prompt: string | Message[], opts?: GenerateOptions): Response; } const DEFAULT_MODEL = "claude-opus-4-8"; const DEFAULT_MAX_TOKENS = 4096; const DEFAULT_BASE_URL = "https://api.anthropic.com"; const DEFAULT_VERSION = "2023-06-01"; function envKey(): string | undefined { // Prefer Bun.env; fall back to process.env (works under Node-compatible runtimes too). const g = globalThis as { Bun?: { env: Record }; process?: { env: Record }; }; return g.Bun?.env?.ANTHROPIC_API_KEY ?? g.process?.env?.ANTHROPIC_API_KEY; } function toMessages(prompt: string | Message[], opts?: GenerateOptions): Message[] { if (opts?.messages?.length) return opts.messages; if (typeof prompt === "string") return [{ role: "user", content: prompt }]; return prompt; } /** Create a Claude client. Reads `ANTHROPIC_API_KEY` from the environment by default. */ export function createAI(config: AIConfig = {}): AI { const baseURL = (config.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, ""); const version = config.version ?? DEFAULT_VERSION; const defaultModel = config.model ?? DEFAULT_MODEL; const defaultMaxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS; const buildBody = ( prompt: string | Message[], opts: GenerateOptions | undefined, stream: boolean, ) => { // NOTE: temperature/top_p/top_k are intentionally omitted — they are rejected // (400) on claude-opus-4-8 and the current Claude models. Steer via prompting. const body: Record = { model: opts?.model ?? defaultModel, max_tokens: opts?.maxTokens ?? defaultMaxTokens, messages: toMessages(prompt, opts), stream, }; if (opts?.system) body.system = opts.system; if (opts?.thinking) body.thinking = { type: "adaptive" }; if (opts?.effort) body.output_config = { effort: opts.effort }; return body; }; const request = async ( prompt: string | Message[], opts: GenerateOptions | undefined, stream: boolean, ): Promise => { const apiKey = config.apiKey ?? envKey(); if (!apiKey) { throw new AIError( "Missing Anthropic API key. Set ANTHROPIC_API_KEY in your environment or pass { apiKey } to createAI().", 0, "authentication_error", ); } const res = await fetch(`${baseURL}/v1/messages`, { method: "POST", headers: { "x-api-key": apiKey, "anthropic-version": version, "content-type": "application/json", }, body: JSON.stringify(buildBody(prompt, opts, stream)), signal: opts?.signal, }); if (!res.ok) { let detail = `${res.status} ${res.statusText}`; let type = "api_error"; try { const err = (await res.json()) as { error?: { message?: string; type?: string } }; if (err.error?.message) detail = err.error.message; if (err.error?.type) type = err.error.type; } catch { /* non-JSON error body */ } throw new AIError(detail, res.status, type); } return res; }; const generate: AI["generate"] = async (prompt, opts) => { const res = await request(prompt, opts, false); const data = (await res.json()) as { stop_reason?: string; content?: Array<{ type: string; text?: string }>; }; if (data.stop_reason === "refusal") { throw new AIError("The model declined to respond to this request.", 200, "refusal"); } return (data.content ?? []) .filter((b) => b.type === "text" && typeof b.text === "string") .map((b) => b.text) .join(""); }; async function* stream( prompt: string | Message[], opts?: GenerateOptions, ): AsyncGenerator { const res = await request(prompt, opts, true); if (!res.body) return; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; const textDelta = (line: string): string | undefined => { if (!line.startsWith("data:")) return undefined; const payload = line.slice(5).trim(); if (!payload || payload === "[DONE]") return undefined; try { const evt = JSON.parse(payload) as { type?: string; delta?: { type?: string; text?: string }; }; return evt.type === "content_block_delta" && evt.delta?.type === "text_delta" ? evt.delta.text : undefined; } catch { return undefined; } }; while (true) { const { done, value } = await reader.read(); if (done) { buf += decoder.decode(); const final = textDelta(buf.trimEnd()); if (final) yield final; break; } buf += decoder.decode(value, { stream: true }); // SSE frames are separated by blank lines; process complete `data:` lines. let nl: number; while ((nl = buf.indexOf("\n")) !== -1) { const line = buf.slice(0, nl).trimEnd(); buf = buf.slice(nl + 1); const delta = textDelta(line); if (delta) yield delta; } } } const streamResponse: AI["streamResponse"] = (prompt, opts) => { const iterator = stream(prompt, opts); const body = new ReadableStream({ async pull(controller) { try { const { value, done } = await iterator.next(); if (done) { controller.close(); return; } controller.enqueue(new TextEncoder().encode(value)); } catch (err) { controller.error(err); } }, }); return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-cache", "x-accel-buffering": "no", }, }); }; return { generate, stream, streamResponse }; } 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";