first commit
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# @wrnexus/ai
|
||||
|
||||
> A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/ai` is a thin, dependency-free wrapper over the Anthropic **Messages API**,
|
||||
built on `fetch` (Bun-native, no SDK). Use it in API routes, jobs, or middleware to
|
||||
call Claude. It defaults to the most capable model, **`claude-opus-4-8`**, reads your
|
||||
key from `ANTHROPIC_API_KEY`, and supports both one-shot generation and streaming.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/ai
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
Set your key in the environment (e.g. `.env`):
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `createAI(config?)`
|
||||
|
||||
Creates a client. The key is read at call time, so it's safe to create at import.
|
||||
|
||||
```ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI(); // or createAI({ apiKey, model, maxTokens, baseURL, version })
|
||||
```
|
||||
|
||||
`AIConfig` fields (all optional):
|
||||
|
||||
| Field | Default | Description |
|
||||
| ----------- | --------------------------- | -------------------------- |
|
||||
| `apiKey` | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||
| `model` | `"claude-opus-4-8"` | Model id |
|
||||
| `maxTokens` | `4096` | Default max output tokens |
|
||||
| `baseURL` | `https://api.anthropic.com` | API base URL |
|
||||
| `version` | `"2023-06-01"` | `anthropic-version` header |
|
||||
|
||||
### `ai.generate(prompt, opts?): Promise<string>`
|
||||
|
||||
One-shot text generation. `prompt` is a string or a `Message[]` history.
|
||||
|
||||
```ts
|
||||
const text = await ai.generate("Write a haiku about Bun.");
|
||||
|
||||
const reply = await ai.generate(
|
||||
[
|
||||
{ role: "user", content: "My name is Ada." },
|
||||
{ role: "assistant", content: "Hi Ada!" },
|
||||
{ role: "user", content: "What's my name?" },
|
||||
],
|
||||
{ system: "You are concise." },
|
||||
);
|
||||
```
|
||||
|
||||
### `ai.stream(prompt, opts?): AsyncGenerator<string>`
|
||||
|
||||
Yields text deltas as they arrive.
|
||||
|
||||
```ts
|
||||
for await (const chunk of ai.stream("Tell me a story.")) {
|
||||
process.stdout.write(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
### `ai.streamResponse(prompt, opts?): Response`
|
||||
|
||||
Returns a streaming `text/plain` `Response` — drop it straight into an API route.
|
||||
|
||||
```ts
|
||||
// app/api/chat.ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI();
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const { prompt } = await ctx.req.json();
|
||||
return ai.streamResponse(prompt);
|
||||
};
|
||||
```
|
||||
|
||||
### `GenerateOptions`
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | ------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `system` | `string` | System prompt |
|
||||
| `model` | `string` | Override the model for this call |
|
||||
| `maxTokens` | `number` | Override max output tokens |
|
||||
| `thinking` | `boolean` | Enable adaptive extended thinking (deeper reasoning) |
|
||||
| `effort` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | Reasoning effort / token spend |
|
||||
| `messages` | `Message[]` | Full history — supersedes `prompt` |
|
||||
| `signal` | `AbortSignal` | Cancel the request |
|
||||
|
||||
> `temperature` / `top_p` are intentionally **not** exposed — the current Claude
|
||||
> models reject them (400). Steer output with prompting instead.
|
||||
|
||||
### `AIError`
|
||||
|
||||
Thrown on non-2xx responses or a model refusal. Carries `.status` and `.type`
|
||||
(e.g. `"authentication_error"`, `"rate_limit_error"`, `"refusal"`).
|
||||
|
||||
```ts
|
||||
import { AIError } from "@wrnexus/ai";
|
||||
try {
|
||||
await ai.generate("...");
|
||||
} catch (e) {
|
||||
if (e instanceof AIError && e.type === "rate_limit_error") {
|
||||
/* back off */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
// app/api/summarize.ts — summarize posted text
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI();
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const { text } = await ctx.req.json().catch(() => ({}));
|
||||
if (!text) return Response.json({ error: "Provide 'text'." }, { status: 400 });
|
||||
const summary = await ai.generate(`Summarize in one sentence:\n\n${text}`, {
|
||||
system: "You are a precise summarizer.",
|
||||
});
|
||||
return Response.json({ summary });
|
||||
};
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Uses `fetch`, `ReadableStream`, `TextDecoder`/`TextEncoder`, and
|
||||
reads `ANTHROPIC_API_KEY` from `Bun.env` (falls back to `process.env`).
|
||||
- **Zero dependencies** — no `@anthropic-ai/sdk`; talks to the Messages API directly.
|
||||
- Defaults to `claude-opus-4-8`. Pass `{ model }` for a different model (e.g.
|
||||
`"claude-sonnet-5"` for speed/cost, `"claude-haiku-4-5"` for the fastest).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @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 };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { AIError, createAI } from "../src/index.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("requires an API key before making a request", async () => {
|
||||
const previous = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
await expect(createAI().generate("hello")).rejects.toBeInstanceOf(AIError);
|
||||
if (previous !== undefined) process.env.ANTHROPIC_API_KEY = previous;
|
||||
});
|
||||
|
||||
test("builds a bounded messages request and joins text blocks", async () => {
|
||||
let request: RequestInit | undefined;
|
||||
globalThis.fetch = (async (_url, init) => {
|
||||
request = init;
|
||||
return Response.json({ content: [{ type: "text", text: "Hello" }, { type: "tool_use" }] });
|
||||
}) as typeof fetch;
|
||||
const text = await createAI({ apiKey: "secret", model: "test-model", maxTokens: 123 }).generate(
|
||||
"Hi",
|
||||
);
|
||||
expect(text).toBe("Hello");
|
||||
expect(JSON.parse(String(request?.body))).toMatchObject({
|
||||
model: "test-model",
|
||||
max_tokens: 123,
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("streams fragmented SSE and keeps a final event without a newline", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode('data: {"type":"content_block_delta","delta":{"type":"text_'),
|
||||
);
|
||||
controller.enqueue(encoder.encode('delta","text":"A"}}\n'));
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"B"}}',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
)) as unknown as typeof fetch;
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of createAI({ apiKey: "secret" }).stream("Hi")) chunks.push(chunk);
|
||||
expect(chunks).toEqual(["A", "B"]);
|
||||
});
|
||||
Reference in New Issue
Block a user