Files
WRNexusJS/packages/ai/src/index.ts
T
2026-07-12 15:55:18 +05:30

248 lines
8.3 KiB
TypeScript

/**
* @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<string>;
/** Stream the response as text deltas, as they arrive. */
stream(prompt: string | Message[], opts?: GenerateOptions): AsyncGenerator<string, void, unknown>;
/** 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<string, string | undefined> };
process?: { env: Record<string, string | undefined> };
};
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<string, unknown> = {
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<Response> => {
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<string, void, unknown> {
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<Uint8Array>({
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 };
}