first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+146
View File
@@ -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).
+11
View File
@@ -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"
}
}
+247
View File
@@ -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 };
}
+56
View File
@@ -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"]);
});