291 lines
9.1 KiB
TypeScript
291 lines
9.1 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 };
|
|
}
|
|
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";
|