merge: integrate D (LIT-2880) — TS SDK with snake↔camel transform

This commit is contained in:
Ishaan Jaffer 2026-05-06 16:47:58 -07:00
commit 84cfae0220
No known key found for this signature in database
24 changed files with 2904 additions and 0 deletions

5
sdks/typescript-agent-sdk/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.DS_Store
package-lock.json

View file

@ -0,0 +1,218 @@
# @litellm/agent-sdk
TypeScript SDK for the LiteLLM Agents API. Mirrors the Cursor SDK's style but
fixes its conflation of agent definition and VM session by exposing a clean
3-level hierarchy:
```
Agent — definition (model, system prompt, tools)
└─ Session — VM sandbox running under an agent
└─ Run — single execution within a session
```
## Install
```bash
npm i @litellm/agent-sdk
# or
pnpm add @litellm/agent-sdk
```
Requires Node 18+ (Node 20+ recommended for `await using` support).
## Quickstart
```ts
import { Agent } from "@litellm/agent-sdk";
// 1. Define an agent (do this once at app startup).
const agent = await Agent.create({
apiKey: process.env.LITELLM_API_KEY,
baseUrl: process.env.LITELLM_BASE_URL, // e.g. http://localhost:4000
name: "shin-cursor",
model: { id: "claude-4.6-sonnet" },
systemPrompt: "You are a senior engineer fixing GitHub issues.",
});
// 2. Spin up a session per workflow (per Slack issue, per ticket, etc).
const session = await agent.createSession({
repos: [{ url: "https://github.com/example/repo", startingRef: "main" }],
envVars: { GITHUB_TOKEN: process.env.GITHUB_TOKEN! },
});
// 3. Kick off a run and stream the events.
const run = await session.send("Fix the bug described in issue #123.");
for await (const event of run.stream()) {
console.log(event.type, event.data);
}
// 4. Wait for the final result.
const result = await run.wait();
console.log(result.status, result.result);
// 5. Tear down the VM when you're done.
await session.delete();
```
## Multi-turn (followups)
```ts
const run = await session.send("Implement the feature.");
const stream = run.stream();
// In another async context, queue a follow-up message into the active run.
await session.followup("Also handle the empty-input edge case, please.");
for await (const event of stream) {
// events from the original run continue to flow, including any
// tool calls / deltas resulting from the follow-up.
}
```
## Resumable streams
Every event carries a monotonic `seq`. Pass `startingSeq` to reconnect from a
specific point — useful if your process restarts mid-stream:
```ts
let lastSeq = 0;
for await (const event of run.stream({ startingSeq: lastSeq + 1 })) {
lastSeq = event.seq;
// ...
}
```
The SDK also auto-reconnects internally when the underlying socket drops. It
sends both `Last-Event-ID: <seq>` (per the SSE spec) and
`?starting_seq=<seq+1>` (LiteLLM-specific); the proxy honors whichever it
understands and replays from there. Already-seen events are de-duped.
## `await using` (Symbol.asyncDispose)
If you're on TypeScript 5.2+ / Node 20+, you can scope a session to a block:
```ts
{
await using session = await agent.createSession();
const run = await session.send("...");
await run.wait();
} // session is DELETEd automatically here
```
`session.terminate()` is a manual alias for `delete()` for older runtimes.
## Reusing a single agent across sessions
```ts
const agent = await Agent.create({ /* ... */ });
// Same agent definition, three independent VM sessions.
const [s1, s2, s3] = await Promise.all([
agent.createSession(),
agent.createSession(),
agent.createSession(),
]);
```
## Authentication
```ts
const agent = await Agent.create({
apiKey: "sk-...", // or set LITELLM_API_KEY in the environment
baseUrl: "https://api.litellm.ai", // default; override for your deployment
// ...
});
```
`apiKey` falls back to `process.env.LITELLM_API_KEY` if not provided. The SDK
sends it as `Authorization: Bearer <apiKey>`.
## Errors
All errors are instances of `LiteLLMAgentError`:
```ts
import { LiteLLMAgentError } from "@litellm/agent-sdk";
try {
await agent.createSession();
} catch (e) {
if (e instanceof LiteLLMAgentError) {
console.error(e.code, e.status, e.retryable);
}
}
```
| Code | Meaning |
| ----------------- | --------------------------------------------- |
| `missing_api_key` | No `apiKey` provided and `LITELLM_API_KEY` unset |
| `not_found` | Agent / session / run does not exist |
| `session_busy` | `send()` called while a run is in flight (409) |
| `rate_limited` | 429 — SDK retried `maxRetries` times |
| `timeout` | Request exceeded `timeoutMs` |
| `http_<status>` | Generic HTTP error fallback |
5xx and 429 are retried automatically with exponential backoff. Configure
`maxRetries` and `timeoutMs` per call via `ClientOptions`.
## Configuration
| Option | Default | Purpose |
| ------------ | ----------------------------- | ------------------------------------ |
| `apiKey` | `process.env.LITELLM_API_KEY` | Bearer token for the proxy |
| `baseUrl` | `https://api.litellm.ai` | Proxy URL |
| `fetch` | `globalThis.fetch` | Override for tests / custom transport |
| `timeoutMs` | `60_000` | Per-request timeout |
| `maxRetries` | `3` | Retries on 5xx/429/network errors |
## API reference
### `Agent`
- `Agent.create(options: AgentCreateOptions): Promise<AgentHandle>`
- `Agent.get(agentId: string, options: ClientOptions): Promise<AgentHandle>`
- `Agent.list(options?: ClientOptions & ListOptions): Promise<ListResult<AgentInfo>>`
### `AgentHandle`
- `agent.id: string`
- `agent.name: string`
- `agent.createSession(options?: CreateSessionOptions): Promise<SessionHandle>`
- `agent.getSession(sessionId: string): Promise<SessionHandle>`
- `agent.listSessions(options?: ListOptions): Promise<ListResult<SessionInfo>>`
- `agent.update(patch: Partial<AgentCreateOptions>): Promise<void>`
- `agent.delete(): Promise<void>` — cascades to sessions
### `SessionHandle`
- `session.id: string`
- `session.agentId: string`
- `session.status: SessionStatus`
- `session.send(input: string | { text; images? }): Promise<Run>` — 409 if a run is in flight
- `session.followup(message: string): Promise<void>` — queues into the active run
- `session.getRun(runId: string): Promise<Run>`
- `session.listRuns(options?: ListOptions): Promise<ListResult<Run>>`
- `session.conversation(): Promise<ConversationTurn[]>`
- `session.delete(): Promise<void>` / `session.terminate()`
- `session[Symbol.asyncDispose]()` — for `await using`
### `Run`
- `run.id: string`
- `run.sessionId: string`
- `run.status: RunStatus`
- `run.result: string | null`
- `run.git?: { branches: { branch; prUrl }[] }`
- `run.stream(opts?: { startingSeq?, signal? }): AsyncIterable<RunEvent>`
- `run.wait(): Promise<RunResult>`
- `run.cancel(): Promise<void>`
- `run.conversation(): Promise<ConversationTurn[]>`
## Examples
See [`examples/basic.ts`](./examples/basic.ts) and
[`examples/followup.ts`](./examples/followup.ts).
## License
MIT — © BerriAI

View file

@ -0,0 +1,42 @@
/**
* Basic example — Agent.create -> session.send -> stream -> wait.
*
* Run with:
* LITELLM_API_KEY=sk-... LITELLM_BASE_URL=http://localhost:4000 \
* npx tsx examples/basic.ts
*/
import { Agent } from "../src/index.js";
async function main(): Promise<void> {
const agent = await Agent.create({
apiKey: process.env.LITELLM_API_KEY,
baseUrl: process.env.LITELLM_BASE_URL,
name: "basic-example",
model: { id: "claude-4.6-sonnet" },
systemPrompt: "You are a helpful assistant.",
});
console.log("agent created:", agent.id);
const session = await agent.createSession();
console.log("session created:", session.id);
const run = await session.send("Say hi in one short sentence.");
console.log("run started:", run.id);
for await (const event of run.stream()) {
console.log(`[${event.seq}] ${event.type}`, event.data);
}
const result = await run.wait();
console.log("final status:", result.status);
console.log("result:", result.result);
await session.delete();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,59 @@
/**
* Multi-turn example — send a long-running prompt and queue a follow-up
* message into the same run.
*
* Run with:
* LITELLM_API_KEY=sk-... LITELLM_BASE_URL=http://localhost:4000 \
* npx tsx examples/followup.ts
*/
import { Agent } from "../src/index.js";
async function main(): Promise<void> {
const agent = await Agent.create({
apiKey: process.env.LITELLM_API_KEY,
baseUrl: process.env.LITELLM_BASE_URL,
name: "followup-example",
model: { id: "claude-4.6-sonnet" },
});
// `await using` automatically tears down the VM at scope exit.
await using session = await agent.createSession({
repos: [
{ url: "https://github.com/example/repo", startingRef: "main" },
],
});
const run = await session.send(
"Implement the new search endpoint described in issue #42."
);
// While the run is in flight, queue a clarification.
setTimeout(() => {
session
.followup("Also handle the empty-input case and add a test for it.")
.catch((e) => console.error("followup failed:", e));
}, 2_000);
for await (const event of run.stream()) {
if (event.type === "delta") {
const data = event.data as { text?: string };
if (data.text) process.stdout.write(data.text);
} else {
console.log(`\n[${event.seq}] ${event.type}`);
}
}
const result = await run.wait();
console.log("\n--- run finished:", result.status, "---");
if (result.git?.branches?.length) {
for (const b of result.git.branches) {
console.log("branch:", b.branch, b.prUrl ?? "(no PR)");
}
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,59 @@
{
"name": "@litellm/agent-sdk",
"version": "0.1.0",
"description": "TypeScript SDK for LiteLLM Agents — Agent → Session → Run hierarchy",
"license": "MIT",
"author": "BerriAI",
"homepage": "https://github.com/BerriAI/litellm/tree/main/sdks/typescript-agent-sdk",
"repository": {
"type": "git",
"url": "git+https://github.com/BerriAI/litellm.git",
"directory": "sdks/typescript-agent-sdk"
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18"
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"examples/**/*.ts\""
},
"dependencies": {
"eventsource-parser": "^3.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"msw": "^2.4.0",
"prettier": "^3.3.0",
"tsup": "^8.2.0",
"typescript": "^5.4.0",
"vitest": "^2.0.0"
},
"keywords": [
"litellm",
"agent",
"sdk",
"ai",
"llm",
"session",
"cursor"
]
}

View file

@ -0,0 +1,135 @@
/**
* Agent — the *definition* of an agent (model, system prompt, tools).
*
* An Agent is not a VM. Use `agent.createSession()` to spin up a VM that runs
* under this agent definition.
*/
import { requestJson, resolveClient, type ResolvedClient } from "./client/http.js";
import { SessionHandle } from "./session.js";
import {
type AgentCreateOptions,
type AgentInfo,
type ClientOptions,
type CreateSessionOptions,
type ListOptions,
type ListResult,
type SessionInfo,
} from "./types.js";
export class AgentHandle {
readonly id: string;
readonly name: string;
private readonly _client: ResolvedClient;
constructor(info: AgentInfo, client: ResolvedClient) {
this.id = info.id;
this.name = info.name;
this._client = client;
}
/** Create a new session running under this agent. */
async createSession(options: CreateSessionOptions = {}): Promise<SessionHandle> {
const info = await requestJson<SessionInfo>(this._client, {
method: "POST",
path: `/v2/agents/${encodeURIComponent(this.id)}/sessions`,
body: {
repos: options.repos ?? [],
envVars: options.envVars ?? {},
metadata: options.metadata ?? {},
},
});
return new SessionHandle(info, this._client);
}
/** Fetch a session by ID under this agent. */
async getSession(sessionId: string): Promise<SessionHandle> {
const info = await requestJson<SessionInfo>(this._client, {
method: "GET",
path: `/v2/agents/${encodeURIComponent(this.id)}/sessions/${encodeURIComponent(sessionId)}`,
});
return new SessionHandle(info, this._client);
}
/** List sessions running under this agent. */
async listSessions(options: ListOptions = {}): Promise<ListResult<SessionInfo>> {
const data = await requestJson<{ items: SessionInfo[]; nextCursor?: string }>(
this._client,
{
method: "GET",
path: `/v2/agents/${encodeURIComponent(this.id)}/sessions`,
query: { limit: options.limit, cursor: options.cursor },
}
);
return { items: data.items ?? [], nextCursor: data.nextCursor };
}
/** Patch the agent definition. */
async update(patch: Partial<AgentCreateOptions>): Promise<void> {
await requestJson<void>(this._client, {
method: "PATCH",
path: `/v2/agents/${encodeURIComponent(this.id)}`,
body: stripClientOptions(patch),
});
}
/** Delete the agent definition. Cascades to its sessions. */
async delete(): Promise<void> {
await requestJson<void>(this._client, {
method: "DELETE",
path: `/v2/agents/${encodeURIComponent(this.id)}`,
});
}
}
export class Agent {
/** Create a new agent definition. */
static async create(options: AgentCreateOptions): Promise<AgentHandle> {
const client = resolveClient(options);
const info = await requestJson<AgentInfo>(client, {
method: "POST",
path: "/v2/agents",
body: stripClientOptions(options),
});
return new AgentHandle(info, client);
}
/** Fetch an existing agent by ID. */
static async get(agentId: string, options: ClientOptions = {}): Promise<AgentHandle> {
const client = resolveClient(options);
const info = await requestJson<AgentInfo>(client, {
method: "GET",
path: `/v2/agents/${encodeURIComponent(agentId)}`,
});
return new AgentHandle(info, client);
}
/** List agents accessible to this API key. */
static async list(
options: ClientOptions & ListOptions = {}
): Promise<ListResult<AgentInfo>> {
const client = resolveClient(options);
const data = await requestJson<{ items: AgentInfo[]; nextCursor?: string }>(
client,
{
method: "GET",
path: "/v2/agents",
query: { limit: options.limit, cursor: options.cursor },
}
);
return { items: data.items ?? [], nextCursor: data.nextCursor };
}
}
function stripClientOptions<T extends Partial<AgentCreateOptions>>(
obj: T
): Omit<T, "apiKey" | "baseUrl" | "fetch" | "timeoutMs" | "maxRetries"> {
const { apiKey, baseUrl, fetch, timeoutMs, maxRetries, ...rest } = obj as AgentCreateOptions;
void apiKey;
void baseUrl;
void fetch;
void timeoutMs;
void maxRetries;
return rest as Omit<T, "apiKey" | "baseUrl" | "fetch" | "timeoutMs" | "maxRetries">;
}

View file

@ -0,0 +1,262 @@
/**
* Thin fetch wrapper for the LiteLLM proxy.
*
* Responsibilities:
* - Resolve apiKey + baseUrl from explicit args or env
* - Inject `Authorization: Bearer <apiKey>`
* - Retry retryable errors (5xx, 429) with exponential backoff
* - Normalize errors to `LiteLLMAgentError`
* - Translate between the SDK's camelCase public API and the backend's
* snake_case wire format. Request bodies are converted with
* `camelToSnake` before serialization; response JSON is converted with
* `snakeToCamel` before being returned to callers. Only object keys are
* rewritten — values pass through unchanged.
*/
import { ClientOptions, LiteLLMAgentError } from "../types.js";
/**
* Recursively convert object keys from snake_case to camelCase.
* Walks plain objects and arrays; leaves Date/Buffer/etc. untouched.
* Single-word keys (no underscores) pass through unchanged.
*/
export function snakeToCamel(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(snakeToCamel);
}
if (isPlainObject(value)) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[snakeToCamelKey(k)] = snakeToCamel(v);
}
return out;
}
return value;
}
/**
* Recursively convert object keys from camelCase to snake_case.
* Walks plain objects and arrays; leaves Date/Buffer/etc. untouched.
* Single-word keys (no uppercase) pass through unchanged.
*/
export function camelToSnake(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(camelToSnake);
}
if (isPlainObject(value)) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[camelToSnakeKey(k)] = camelToSnake(v);
}
return out;
}
return value;
}
function snakeToCamelKey(key: string): string {
if (!key.includes("_")) return key;
return key.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase());
}
function camelToSnakeKey(key: string): string {
// Only rewrite if there's at least one uppercase letter to convert.
if (!/[A-Z]/.test(key)) return key;
return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
if (v === null || typeof v !== "object") return false;
const proto = Object.getPrototypeOf(v);
return proto === Object.prototype || proto === null;
}
const DEFAULT_BASE_URL = "https://api.litellm.ai";
const DEFAULT_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_RETRIES = 3;
/** Upper bound for honoring `Retry-After` headers, to defend against a
* misbehaving or adversarial server returning extreme values. */
const MAX_RETRY_AFTER_MS = 60_000;
export interface ResolvedClient {
apiKey: string;
baseUrl: string;
fetch: typeof fetch;
timeoutMs: number;
maxRetries: number;
}
export function resolveClient(options: ClientOptions = {}): ResolvedClient {
const apiKey =
options.apiKey ??
(typeof process !== "undefined" ? process.env?.LITELLM_API_KEY : undefined);
if (!apiKey) {
throw new LiteLLMAgentError(
"Missing apiKey. Pass `apiKey` explicitly or set LITELLM_API_KEY in the environment.",
{ code: "missing_api_key" },
);
}
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
return {
apiKey,
baseUrl,
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
};
}
export interface RequestOpts {
method?: "GET" | "POST" | "PATCH" | "DELETE";
path: string;
body?: unknown;
query?: Record<string, string | number | undefined>;
headers?: Record<string, string>;
/** If true, the response is returned raw (used for SSE). */
stream?: boolean;
/** Abort signal forwarded to fetch. */
signal?: AbortSignal;
}
export async function request(
client: ResolvedClient,
opts: RequestOpts,
): Promise<Response> {
const url = buildUrl(client.baseUrl, opts.path, opts.query);
const headers: Record<string, string> = {
Authorization: `Bearer ${client.apiKey}`,
Accept: opts.stream ? "text/event-stream" : "application/json",
...(opts.headers ?? {}),
};
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
}
const init: RequestInit = {
method: opts.method ?? "GET",
headers,
body:
opts.body !== undefined
? JSON.stringify(camelToSnake(opts.body))
: undefined,
signal: opts.signal,
};
let lastError: unknown;
for (let attempt = 0; attempt <= client.maxRetries; attempt++) {
try {
const res = await withTimeout(client.fetch(url, init), client.timeoutMs);
if (!res.ok) {
const err = await toAgentError(res);
if (err.retryable && attempt < client.maxRetries) {
await sleep(backoffMs(attempt, res));
continue;
}
throw err;
}
return res;
} catch (e) {
lastError = e;
if (e instanceof LiteLLMAgentError) {
if (!e.retryable || attempt === client.maxRetries) throw e;
await sleep(backoffMs(attempt));
continue;
}
// Network errors are retryable.
if (attempt === client.maxRetries) throw e;
await sleep(backoffMs(attempt));
}
}
throw lastError;
}
export async function requestJson<T>(
client: ResolvedClient,
opts: RequestOpts,
): Promise<T> {
const res = await request(client, opts);
if (res.status === 204) return undefined as unknown as T;
const parsed = await res.json();
return snakeToCamel(parsed) as T;
}
function buildUrl(
baseUrl: string,
path: string,
query?: Record<string, string | number | undefined>,
): string {
const url = new URL(path.startsWith("/") ? path : `/${path}`, baseUrl + "/");
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v === undefined) continue;
url.searchParams.set(k, String(v));
}
}
return url.toString();
}
async function toAgentError(res: Response): Promise<LiteLLMAgentError> {
const status = res.status;
let code = `http_${status}`;
let message = `HTTP ${status}`;
try {
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) {
const body = (await res.json()) as {
error?: { code?: string; message?: string };
detail?: unknown;
};
if (body?.error?.code) code = body.error.code;
if (body?.error?.message) message = body.error.message;
else if (typeof body?.detail === "string") message = body.detail;
} else {
const text = await res.text();
if (text) message = text.slice(0, 500);
}
} catch {
// ignore body-parse failures
}
const retryable = status >= 500 || status === 429;
return new LiteLLMAgentError(message, { code, status, retryable });
}
function backoffMs(attempt: number, res?: Response): number {
if (res) {
const ra = res.headers.get("retry-after");
if (ra) {
const n = Number(ra);
if (!Number.isNaN(n)) {
return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, n * 1000));
}
}
}
// 250ms, 500ms, 1s, 2s, ...
return 250 * Math.pow(2, attempt);
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
if (!ms || ms <= 0) return p;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new LiteLLMAgentError(`Request timed out after ${ms}ms`, {
code: "timeout",
retryable: true,
}),
),
ms,
);
});
try {
return (await Promise.race([p, timeout])) as T;
} finally {
if (timer) clearTimeout(timer);
}
}

View file

@ -0,0 +1,186 @@
/**
* SSE client with auto-reconnect via `Last-Event-ID` / `?starting_seq=N`.
*
* Yields parsed `RunEvent` objects. When the stream drops, it transparently
* reconnects from the last seq it saw. Honors AbortSignal.
*/
import { createParser, type EventSourceMessage } from "eventsource-parser";
import { LiteLLMAgentError, type RunEvent } from "../types.js";
import { request, type ResolvedClient } from "./http.js";
export interface StreamRunOptions {
startingSeq?: number;
signal?: AbortSignal;
/** Max reconnect attempts on dropped sockets. Default: 5. */
maxReconnects?: number;
}
/**
* Stream events from `GET /v2/sessions/{sid}/runs/{rid}/events`.
*
* Resume contract:
* - On every event, we record `seq`.
* - On reconnect, we send both `Last-Event-ID: <seq>` (per the SSE spec)
* and `?starting_seq=<seq+1>` (LiteLLM-specific). The proxy honors
* whichever it understands and replays from there.
*/
export async function* streamRunEvents(
client: ResolvedClient,
sessionId: string,
runId: string,
opts: StreamRunOptions = {},
): AsyncIterable<RunEvent> {
const maxReconnects = opts.maxReconnects ?? 5;
let lastSeq = opts.startingSeq !== undefined ? opts.startingSeq - 1 : -1;
let reconnects = 0;
while (true) {
const startingSeq = lastSeq >= 0 ? lastSeq + 1 : undefined;
let res: Response;
try {
res = await request(client, {
method: "GET",
path: `/v2/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}/events`,
query:
startingSeq !== undefined ? { starting_seq: startingSeq } : undefined,
headers:
startingSeq !== undefined ? { "Last-Event-ID": String(lastSeq) } : {},
stream: true,
signal: opts.signal,
});
} catch (e) {
if (opts.signal?.aborted) return;
if (reconnects >= maxReconnects) throw e;
reconnects++;
await sleep(backoffMs(reconnects));
continue;
}
if (!res.body) {
throw new LiteLLMAgentError("SSE response had no body", {
code: "stream_no_body",
});
}
let droppedMidStream = false;
let sawProgressOnThisConnection = false;
try {
for await (const event of parseSSE(res.body, opts.signal)) {
const parsed = decodeEvent(event);
if (parsed === null) continue;
if (parsed.seq <= lastSeq) {
// Replayed event we already saw; drop it.
continue;
}
lastSeq = parsed.seq;
sawProgressOnThisConnection = true;
yield parsed;
if (parsed.type === "done" || parsed.type === "error") {
return;
}
}
// Stream ended cleanly without a `done` event — server hung up.
droppedMidStream = true;
} catch (e) {
if (opts.signal?.aborted) return;
droppedMidStream = true;
}
if (!droppedMidStream) return;
// Reset the reconnect budget if this connection delivered new events.
// The counter tracks *consecutive* failures, not lifetime drops, so a
// long-running stream with sporadic transient drops is not penalized.
if (sawProgressOnThisConnection) {
reconnects = 0;
}
if (reconnects >= maxReconnects) {
throw new LiteLLMAgentError(
`SSE reconnect budget exhausted (${maxReconnects})`,
{ code: "sse_reconnect_exhausted" },
);
}
reconnects++;
await sleep(backoffMs(reconnects));
}
}
function decodeEvent(msg: EventSourceMessage): RunEvent | null {
if (!msg.data) return null;
try {
const obj = JSON.parse(msg.data) as Partial<RunEvent>;
if (typeof obj.seq !== "number" || typeof obj.type !== "string") {
return null;
}
return {
seq: obj.seq,
type: obj.type,
data: obj.data ?? null,
};
} catch {
return null;
}
}
async function* parseSSE(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): AsyncIterable<EventSourceMessage> {
const queue: EventSourceMessage[] = [];
const state: { resolve: (() => void) | null; done: boolean } = {
resolve: null,
done: false,
};
const parser = createParser({
onEvent: (e) => {
queue.push(e);
state.resolve?.();
},
});
const reader = body.getReader();
const decoder = new TextDecoder();
const pump = (async () => {
try {
while (true) {
if (signal?.aborted) {
await reader.cancel();
return;
}
const { value, done: rdone } = await reader.read();
if (rdone) return;
parser.feed(decoder.decode(value, { stream: true }));
state.resolve?.();
}
} finally {
state.done = true;
state.resolve?.();
}
})();
try {
while (true) {
while (queue.length > 0) {
const ev = queue.shift()!;
yield ev;
}
if (state.done) return;
await new Promise<void>((r) => {
state.resolve = r;
});
state.resolve = null;
}
} finally {
await pump.catch(() => {});
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function backoffMs(attempt: number): number {
return Math.min(5_000, 250 * Math.pow(2, attempt));
}

View file

@ -0,0 +1,35 @@
/**
* @litellm/agent-sdk — public surface.
*
* Three-level hierarchy:
* Agent — definition (model, system prompt, tools)
* ↓
* Session — VM sandbox running under an agent
* ↓
* Run — single execution within a session
*/
export { Agent, AgentHandle } from "./agent.js";
export { SessionHandle } from "./session.js";
export { Run } from "./run.js";
export type {
AgentCreateOptions,
AgentInfo,
ClientOptions,
ConversationTurn,
CreateSessionOptions,
ListOptions,
ListResult,
Message,
ModelRef,
RunEvent,
RunInfo,
RunResult,
RunStatus,
SDKImage,
SessionInfo,
SessionStatus,
} from "./types.js";
export { LiteLLMAgentError } from "./types.js";

View file

@ -0,0 +1,135 @@
/**
* Run — a single execution of an agent inside a session.
*
* Lifecycle: queued → running → completed | failed | cancelled.
*/
import { requestJson, type ResolvedClient } from "./client/http.js";
import { streamRunEvents } from "./client/sse.js";
import {
LiteLLMAgentError,
type RunEvent,
type RunInfo,
type RunResult,
type RunStatus,
} from "./types.js";
const TERMINAL_STATES: RunStatus[] = ["finished", "cancelled", "error"];
const DEFAULT_WAIT_POLL_MS = 500;
export class Run {
readonly id: string;
readonly sessionId: string;
private _status: RunStatus;
private _result: string | null;
private _git: RunInfo["git"];
private readonly _client: ResolvedClient;
constructor(info: RunInfo, client: ResolvedClient) {
this.id = info.id;
this.sessionId = info.sessionId;
this._status = info.status;
this._result = info.result;
this._git = info.git;
this._client = client;
}
get status(): RunStatus {
return this._status;
}
get result(): string | null {
return this._result;
}
get git(): RunInfo["git"] {
return this._git;
}
/** Open an SSE stream of events for this run. */
stream(
opts: { startingSeq?: number; signal?: AbortSignal } = {},
): AsyncIterable<RunEvent> {
return streamRunEvents(this._client, this.sessionId, this.id, opts);
}
/**
* Block until the run reaches a terminal status.
*
* @param opts.signal Optional `AbortSignal` to bail out early. Throws
* `LiteLLMAgentError({ code: "wait_aborted" })`.
* @param opts.timeoutMs Optional ceiling on total wait time. Throws
* `LiteLLMAgentError({ code: "wait_timeout" })`.
* @param opts.pollMs Override the poll interval (default 500ms).
*/
async wait(
opts: { signal?: AbortSignal; timeoutMs?: number; pollMs?: number } = {},
): Promise<RunResult> {
const pollMs = opts.pollMs ?? DEFAULT_WAIT_POLL_MS;
const deadline =
opts.timeoutMs !== undefined && opts.timeoutMs > 0
? Date.now() + opts.timeoutMs
: undefined;
while (!TERMINAL_STATES.includes(this._status)) {
if (opts.signal?.aborted) {
throw new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" });
}
if (deadline !== undefined && Date.now() >= deadline) {
throw new LiteLLMAgentError(
`wait() timed out after ${opts.timeoutMs}ms`,
{ code: "wait_timeout" },
);
}
const info = await requestJson<RunInfo>(this._client, {
method: "GET",
path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}`,
signal: opts.signal,
});
this._status = info.status;
this._result = info.result;
this._git = info.git;
if (TERMINAL_STATES.includes(this._status)) break;
await sleep(pollMs, opts.signal);
}
return {
id: this.id,
status: this._status,
result: this._result,
git: this._git,
};
}
// NOTE: There is no per-run conversation endpoint on the backend. The
// conversation is session-scoped — call `session.conversation()` (on the
// owning `SessionHandle`) for the full turn history.
/** Cancel a running run; no-op if already terminal. */
async cancel(): Promise<void> {
if (TERMINAL_STATES.includes(this._status)) return;
await requestJson<void>(this._client, {
method: "POST",
path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/cancel`,
});
this._status = "cancelled";
}
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" }));
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" }));
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}

View file

@ -0,0 +1,137 @@
/**
* SessionHandle — a single VM session running under one agent.
*
* Each session is a long-lived sandbox (Phase 1: noop VM) that processes
* one or more `Run`s sequentially. `send()` starts a new run; `followup()`
* queues a message into the active run.
*/
import { requestJson, type ResolvedClient } from "./client/http.js";
import { Run } from "./run.js";
import {
type ConversationTurn,
type ListOptions,
type ListResult,
type RunInfo,
type SDKImage,
type SessionInfo,
type SessionStatus,
} from "./types.js";
interface SendInput {
text: string;
images?: SDKImage[];
}
export class SessionHandle {
readonly id: string;
readonly agentId: string;
private _status: SessionStatus;
private readonly _client: ResolvedClient;
constructor(info: SessionInfo, client: ResolvedClient) {
this.id = info.id;
this.agentId = info.agentId;
this._status = info.status;
this._client = client;
}
get status(): SessionStatus {
return this._status;
}
/** Start a new run with `input`. Throws 409 if a run is already in flight. */
async send(input: string | SendInput): Promise<Run> {
const body = normalizeSendInput(input);
const info = await requestJson<RunInfo>(this._client, {
method: "POST",
path: `/v2/sessions/${encodeURIComponent(this.id)}/runs`,
body,
});
return new Run(info, this._client);
}
/** Queue a follow-up message into the active run. */
async followup(message: string): Promise<void> {
// Wire shape matches backend `FollowupCreate`: {prompt: {text: "..."}}.
await requestJson<void>(this._client, {
method: "POST",
path: `/v2/sessions/${encodeURIComponent(this.id)}/followup`,
body: { prompt: { text: message } },
});
}
/** Fetch a single run by ID. */
async getRun(runId: string): Promise<Run> {
const info = await requestJson<RunInfo>(this._client, {
method: "GET",
path: `/v2/sessions/${encodeURIComponent(this.id)}/runs/${encodeURIComponent(runId)}`,
});
return new Run(info, this._client);
}
/** List runs belonging to this session. */
async listRuns(options: ListOptions = {}): Promise<ListResult<Run>> {
const data = await requestJson<{ items: RunInfo[]; nextCursor?: string }>(
this._client,
{
method: "GET",
path: `/v2/sessions/${encodeURIComponent(this.id)}/runs`,
query: { limit: options.limit, cursor: options.cursor },
},
);
return {
items: (data.items ?? []).map((info) => new Run(info, this._client)),
nextCursor: data.nextCursor,
};
}
/** Snapshot of the full conversation across runs. */
async conversation(): Promise<ConversationTurn[]> {
const data = await requestJson<{ turns: ConversationTurn[] }>(
this._client,
{
method: "GET",
path: `/v2/sessions/${encodeURIComponent(this.id)}/conversation`,
},
);
return data.turns ?? [];
}
/** Tear down the VM and delete the session. */
async delete(): Promise<void> {
await requestJson<void>(this._client, {
method: "DELETE",
path: `/v2/sessions/${encodeURIComponent(this.id)}`,
});
this._status = "terminated";
}
/** Alias of `delete()`. */
async terminate(): Promise<void> {
await this.delete();
}
/**
* Enables `await using session = await agent.createSession(...)`.
* Calls DELETE on scope exit.
*/
async [Symbol.asyncDispose](): Promise<void> {
if (this._status !== "terminated") {
try {
await this.delete();
} catch {
// Best-effort cleanup — do not throw out of dispose.
}
}
}
}
function normalizeSendInput(input: string | SendInput): {
text: string;
images: SDKImage[];
} {
if (typeof input === "string") return { text: input, images: [] };
return { text: input.text, images: input.images ?? [] };
}

View file

@ -0,0 +1,160 @@
/**
* Public types for @litellm/agent-sdk.
*
* These mirror the wire shapes posted by Epic A on LIT-2880. When A1
* publishes the canonical OpenAPI/wire-shape comment, regenerate the
* `Wire*` aliases below; the public surface (Agent/Session/Run) should
* remain stable.
*/
/** Configuration shared by every SDK call. */
export interface ClientOptions {
/** LiteLLM API key. Falls back to `process.env.LITELLM_API_KEY`. */
apiKey?: string;
/** Base URL of the LiteLLM proxy. Defaults to `https://api.litellm.ai`. */
baseUrl?: string;
/** Optional fetch override for tests / custom transports. */
fetch?: typeof fetch;
/** Default request timeout (ms). Default: 60_000. */
timeoutMs?: number;
/** Max retry attempts on retryable errors. Default: 3. */
maxRetries?: number;
}
/** Pagination args accepted by `list*` methods. */
export interface ListOptions {
limit?: number;
cursor?: string;
}
export interface ListResult<T> {
items: T[];
nextCursor?: string;
}
/** Used to identify the model an agent should run on. */
export interface ModelRef {
/** LiteLLM model ID, e.g. `claude-4.6-sonnet`. */
id: string;
}
/** Image attachment passed to `session.send`. */
export interface SDKImage {
/** Either a data URL or an https URL. */
url: string;
/** Optional MIME type hint. */
mimeType?: string;
}
/** Args accepted by `Agent.create`. */
export interface AgentCreateOptions extends ClientOptions {
name: string;
model: ModelRef;
systemPrompt?: string;
/** Free-form tags / metadata persisted on the agent definition. */
metadata?: Record<string, string>;
}
/** Args accepted by `agent.createSession`. */
export interface CreateSessionOptions {
/** Repos to clone into the session VM. */
repos?: { url: string; startingRef?: string }[];
/** Environment variables surfaced to the VM. */
envVars?: Record<string, string>;
/** Free-form tags / metadata persisted on the session. */
metadata?: Record<string, string>;
}
export type SessionStatus =
| "provisioning"
| "ready"
| "busy"
| "error"
| "terminated";
export type RunStatus =
| "queued"
| "running"
| "finished"
| "cancelled"
| "error";
/** Lightweight info returned by list endpoints. */
export interface AgentInfo {
id: string;
name: string;
model: ModelRef;
createdAt: string;
}
export interface SessionInfo {
id: string;
agentId: string;
status: SessionStatus;
createdAt: string;
}
export interface RunInfo {
id: string;
sessionId: string;
status: RunStatus;
result: string | null;
startedAt: string | null;
completedAt: string | null;
git?: { branches: { branch: string; prUrl: string | null }[] };
}
export interface ConversationTurn {
role: "user" | "assistant" | "system" | "tool";
content: string;
runId?: string;
createdAt: string;
}
export interface Message extends ConversationTurn {}
/** Streamed event off `run.stream()`. */
export interface RunEvent {
/** Monotonic per-run sequence; required for resume. */
seq: number;
type:
| "delta"
| "tool_call"
| "tool_result"
| "status"
| "done"
| "error"
| string;
data: unknown;
}
/** Resolved value from `run.wait()`. */
export interface RunResult {
id: string;
status: RunStatus;
result: string | null;
git?: { branches: { branch: string; prUrl: string | null }[] };
}
/** Error thrown by the SDK. Mirrors Cursor SDK's `CursorAgentError` shape. */
export class LiteLLMAgentError extends Error {
/** Stable machine-readable code, e.g. `not_found`, `rate_limited`. */
code: string;
/** HTTP status, when applicable. */
status?: number;
/** Whether the call is safe to retry. */
retryable: boolean;
constructor(
message: string,
options: { code: string; status?: number; retryable?: boolean } = {
code: "unknown",
},
) {
super(message);
this.name = "LiteLLMAgentError";
this.code = options.code;
this.status = options.status;
this.retryable = options.retryable ?? false;
}
}

View file

@ -0,0 +1,59 @@
/**
* Validation #3 — Agent reuse across sessions.
*
* Creates ONE agent, then 3 sessions under it. Asserts: same agentId,
* distinct sessionIds, distinct VMs.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { MockProxy } from "./mock-proxy.js";
describe("agent reuse across sessions", () => {
let proxy: MockProxy;
let baseUrl: string;
beforeEach(async () => {
proxy = new MockProxy();
baseUrl = await proxy.start();
});
afterEach(async () => {
await proxy.stop();
});
it("creates 3 sessions under one agent with distinct IDs and distinct VMs", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "shared",
model: { id: "test-model" },
});
const sessions = await Promise.all([
agent.createSession(),
agent.createSession(),
agent.createSession(),
]);
// All sessions share the agent ID.
for (const s of sessions) {
expect(s.agentId).toBe(agent.id);
}
// Session IDs are unique.
const ids = sessions.map((s) => s.id);
expect(new Set(ids).size).toBe(3);
// Mock proxy assigns each session a distinct VM ID.
const proxyAgent = proxy.agents.get(agent.id);
expect(proxyAgent).toBeDefined();
const vmIds = [...proxyAgent!.sessions.values()].map((s) => s.vmId);
expect(new Set(vmIds).size).toBe(3);
// listSessions reflects all 3.
const listed = await agent.listSessions();
expect(listed.items.length).toBe(3);
expect(new Set(listed.items.map((i) => i.id))).toEqual(new Set(ids));
});
});

View file

@ -0,0 +1,123 @@
/**
* Spec tests for `Agent` static API.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Agent, LiteLLMAgentError } from "../src/index.js";
import { MockProxy } from "./mock-proxy.js";
describe("Agent", () => {
let proxy: MockProxy;
let baseUrl: string;
beforeEach(async () => {
proxy = new MockProxy();
baseUrl = await proxy.start();
});
afterEach(async () => {
await proxy.stop();
});
it("Agent.create persists name + model and returns an AgentHandle", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "alpha",
model: { id: "claude-4.6-sonnet" },
systemPrompt: "be helpful",
metadata: { team: "infra" },
});
expect(agent.id).toMatch(/^agt_/);
expect(agent.name).toBe("alpha");
const proxyAgent = proxy.agents.get(agent.id);
expect(proxyAgent?.model.id).toBe("claude-4.6-sonnet");
expect(proxyAgent?.systemPrompt).toBe("be helpful");
expect(proxyAgent?.metadata.team).toBe("infra");
});
it("Agent.get round-trips a previously-created agent", async () => {
const created = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "fetch-me",
model: { id: "test-model" },
});
const fetched = await Agent.get(created.id, { apiKey: "test-key", baseUrl });
expect(fetched.id).toBe(created.id);
expect(fetched.name).toBe("fetch-me");
});
it("Agent.delete removes the agent from the server", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "transient",
model: { id: "test-model" },
});
expect(proxy.agents.has(agent.id)).toBe(true);
await agent.delete();
expect(proxy.agents.has(agent.id)).toBe(false);
});
it("rejects with a 401 LiteLLMAgentError when apiKey is wrong", async () => {
await expect(
Agent.create({
apiKey: "wrong-key",
baseUrl,
name: "x",
model: { id: "test-model" },
// no retries on 401
maxRetries: 0,
})
).rejects.toMatchObject({
name: "LiteLLMAgentError",
status: 401,
});
});
it("throws missing_api_key when no apiKey provided and env is unset", async () => {
const previous = process.env.LITELLM_API_KEY;
delete process.env.LITELLM_API_KEY;
try {
await expect(
// @ts-expect-error — testing runtime guard with missing apiKey
Agent.create({ baseUrl, name: "x", model: { id: "m" } })
).rejects.toMatchObject({
name: "LiteLLMAgentError",
code: "missing_api_key",
});
} finally {
if (previous !== undefined) process.env.LITELLM_API_KEY = previous;
}
});
it("retries on a transient 503 with retry-after and ultimately succeeds", async () => {
const proxy503 = new MockProxy({ failFirstRequest: true });
const url503 = await proxy503.start();
try {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl: url503,
name: "resilient",
model: { id: "test-model" },
});
expect(agent.id).toMatch(/^agt_/);
} finally {
await proxy503.stop();
}
});
it("LiteLLMAgentError carries code, status, and retryable flag", () => {
const e = new LiteLLMAgentError("nope", {
code: "rate_limited",
status: 429,
retryable: true,
});
expect(e.code).toBe("rate_limited");
expect(e.status).toBe(429);
expect(e.retryable).toBe(true);
expect(e.name).toBe("LiteLLMAgentError");
});
});

View file

@ -0,0 +1,63 @@
/**
* Validation #6 — Async dispose tears down VM.
*
* Uses `await using session = ...`; checks proxy state shows session
* terminated after scope exit.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Agent, type SessionHandle } from "../src/index.js";
import { MockProxy } from "./mock-proxy.js";
describe("Symbol.asyncDispose", () => {
let proxy: MockProxy;
let baseUrl: string;
beforeEach(async () => {
proxy = new MockProxy();
baseUrl = await proxy.start();
});
afterEach(async () => {
await proxy.stop();
});
it("calls DELETE /v2/sessions/{id} when the await-using scope exits", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "disposer",
model: { id: "test-model" },
});
let sessionId: string;
{
// `await using` requires TS 5.2+ and proper polyfill. We invoke the
// dispose method explicitly so the test is portable across runners
// without forcing the explicit-resource-management transform.
const session: SessionHandle = await agent.createSession();
sessionId = session.id;
const proxySession = proxy.findSession(sessionId);
expect(proxySession?.status).not.toBe("terminated");
await session[Symbol.asyncDispose]();
}
const after = proxy.findSession(sessionId);
expect(after?.status).toBe("terminated");
});
it("dispose is a no-op once the session has already been deleted", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "double-dispose",
model: { id: "test-model" },
});
const session = await agent.createSession();
await session.delete();
// A second dispose must not throw.
await expect(session[Symbol.asyncDispose]()).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,57 @@
/**
* Validation #5 — session.followup() queues correctly.
*
* Sends a long prompt via send(); while it's running, calls followup().
* Expect: no 409, follow-up message appears in conversation after current
* run terminal.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { MockProxy } from "./mock-proxy.js";
describe("session.followup()", () => {
let proxy: MockProxy;
let baseUrl: string;
beforeEach(async () => {
proxy = new MockProxy();
baseUrl = await proxy.start();
});
afterEach(async () => {
await proxy.stop();
});
it("accepts a follow-up message while a run is in flight without a 409", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "follower",
model: { id: "test-model" },
});
const session = await agent.createSession();
const run = await session.send("the long initial prompt");
// Simulate run-in-progress by leaving status non-terminal until we say so.
// followup() should NOT throw a 409.
await expect(
session.followup("please also handle the empty-input case")
).resolves.toBeUndefined();
// Drive the run to completion.
proxy.emit(run.id, "delta", { text: "ok" });
proxy.complete(run.id, "done");
await run.wait();
const turns = await session.conversation();
const userTurns = turns.filter((t) => t.role === "user").map((t) => t.content);
expect(userTurns).toContain("the long initial prompt");
expect(userTurns).toContain("please also handle the empty-input case");
// Follow-up should be ordered after the initial prompt.
const initialIdx = userTurns.indexOf("the long initial prompt");
const followupIdx = userTurns.indexOf("please also handle the empty-input case");
expect(followupIdx).toBeGreaterThan(initialIdx);
});
});

View file

@ -0,0 +1,523 @@
/**
* In-memory mock proxy for SDK tests.
*
* We don't use msw here because we need to test SSE streaming + mid-stream
* socket drops, which fetch interception libraries handle awkwardly.
* Instead we spin up a real `http.Server` on a random port and point the SDK
* at it.
*/
import http, { type IncomingMessage, type ServerResponse } from "node:http";
import { AddressInfo } from "node:net";
interface AgentRecord {
id: string;
name: string;
model: { id: string };
systemPrompt?: string;
metadata: Record<string, string>;
createdAt: string;
sessions: Map<string, SessionRecord>;
}
interface SessionRecord {
id: string;
agentId: string;
status: "provisioning" | "ready" | "busy" | "error" | "terminated";
vmId: string;
createdAt: string;
runs: Map<string, RunRecord>;
followups: string[];
conversation: {
role: string;
content: string;
runId?: string;
createdAt: string;
}[];
}
interface RunRecord {
id: string;
sessionId: string;
status: "queued" | "running" | "finished" | "cancelled" | "error";
result: string | null;
events: { seq: number; type: string; data: unknown }[];
startedAt: string | null;
completedAt: string | null;
/** active SSE connections — set when /events is connected */
sseClients: Set<ServerResponse>;
}
export interface MockProxyOptions {
/**
* If set, the first SSE connection for a run is killed after this many ms,
* to simulate a mid-stream socket drop. Reset to `false` after the first
* drop to let reconnection succeed.
*/
killSseAfterMs?: number;
/** If true, emit a 503 with retry-after on the first request. */
failFirstRequest?: boolean;
}
export class MockProxy {
readonly server: http.Server;
readonly agents: Map<string, AgentRecord> = new Map();
private firstRequestServed = false;
private firstStreamDropped = false;
private idCounter = 0;
options: MockProxyOptions;
constructor(options: MockProxyOptions = {}) {
this.options = options;
this.server = http.createServer((req, res) => this.handle(req, res));
}
async start(): Promise<string> {
await new Promise<void>((resolve) =>
this.server.listen(0, "127.0.0.1", resolve),
);
const addr = this.server.address() as AddressInfo;
return `http://127.0.0.1:${addr.port}`;
}
async stop(): Promise<void> {
// Force-close any open SSE clients first.
for (const agent of this.agents.values()) {
for (const session of agent.sessions.values()) {
for (const run of session.runs.values()) {
for (const c of run.sseClients) c.end();
run.sseClients.clear();
}
}
}
await new Promise<void>((resolve, reject) =>
this.server.close((err) => (err ? reject(err) : resolve())),
);
}
/** Lookup helpers exposed for tests. */
findRun(runId: string): RunRecord | undefined {
for (const agent of this.agents.values()) {
for (const session of agent.sessions.values()) {
const run = session.runs.get(runId);
if (run) return run;
}
}
return undefined;
}
findSession(sessionId: string): SessionRecord | undefined {
for (const agent of this.agents.values()) {
const s = agent.sessions.get(sessionId);
if (s) return s;
}
return undefined;
}
/** Append a new event to a run, fan it out to live SSE clients. */
emit(runId: string, type: string, data: unknown): void {
const run = this.findRun(runId);
if (!run) return;
const seq = run.events.length;
const event = { seq, type, data };
run.events.push(event);
const payload = `id: ${seq}\ndata: ${JSON.stringify(event)}\n\n`;
for (const client of run.sseClients) {
client.write(payload);
}
}
/** Mark a run completed and emit a `done` event. */
complete(runId: string, result: string): void {
const run = this.findRun(runId);
if (!run) return;
run.status = "finished";
run.result = result;
run.completedAt = new Date().toISOString();
this.emit(runId, "done", { result });
for (const client of run.sseClients) client.end();
run.sseClients.clear();
}
private nextId(prefix: string): string {
this.idCounter++;
return `${prefix}_${this.idCounter}`;
}
private async handle(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
if (this.options.failFirstRequest && !this.firstRequestServed) {
this.firstRequestServed = true;
res.writeHead(503, {
"retry-after": "0",
"content-type": "application/json",
});
res.end(
JSON.stringify({ error: { code: "transient", message: "boom" } }),
);
return;
}
this.firstRequestServed = true;
if (req.headers.authorization !== "Bearer test-key") {
res.writeHead(401, { "content-type": "application/json" });
res.end(
JSON.stringify({ error: { code: "unauthorized", message: "bad key" } }),
);
return;
}
const url = new URL(req.url ?? "/", "http://localhost");
const method = req.method ?? "GET";
const segments = url.pathname.split("/").filter(Boolean);
try {
if (segments[0] !== "v2") {
return notFound(res);
}
// /v2/agents
if (segments.length === 2 && segments[1] === "agents") {
if (method === "POST") return this.createAgent(req, res);
if (method === "GET") return this.listAgents(res);
}
if (segments.length === 3 && segments[1] === "agents") {
const agent = this.agents.get(segments[2]);
if (!agent) return notFound(res);
if (method === "GET") return ok(res, this.serializeAgent(agent));
if (method === "DELETE") {
this.agents.delete(agent.id);
return ok(res, { ok: true });
}
}
if (
segments.length === 4 &&
segments[1] === "agents" &&
segments[3] === "sessions"
) {
const agent = this.agents.get(segments[2]);
if (!agent) return notFound(res);
if (method === "POST") return this.createSession(agent, req, res);
if (method === "GET")
return ok(res, {
items: [...agent.sessions.values()].map(serializeSession),
});
}
if (
segments.length === 5 &&
segments[1] === "agents" &&
segments[3] === "sessions"
) {
const agent = this.agents.get(segments[2]);
const session = agent?.sessions.get(segments[4]);
if (!session) return notFound(res);
if (method === "GET") return ok(res, serializeSession(session));
}
// /v2/sessions/{sid}/...
if (segments[1] === "sessions" && segments.length >= 3) {
const session = this.findSession(segments[2]);
if (!session) return notFound(res);
if (segments.length === 3) {
if (method === "DELETE") {
session.status = "terminated";
return ok(res, { ok: true });
}
}
if (
segments.length === 4 &&
segments[3] === "runs" &&
method === "POST"
) {
return this.createRun(session, req, res);
}
if (
segments.length === 4 &&
segments[3] === "runs" &&
method === "GET"
) {
return ok(res, {
items: [...session.runs.values()].map(serializeRun),
});
}
if (
segments.length === 4 &&
segments[3] === "followup" &&
method === "POST"
) {
const body = await readJson(req);
// Wire format: {prompt: {text: "..."}} per FollowupCreate.
const text = body?.prompt?.text ?? "";
session.followups.push(text);
session.conversation.push({
role: "user",
content: text,
createdAt: new Date().toISOString(),
});
return ok(res, { ok: true });
}
if (
segments.length === 4 &&
segments[3] === "conversation" &&
method === "GET"
) {
return ok(res, { turns: session.conversation.map(serializeTurn) });
}
if (segments.length === 5 && segments[3] === "runs") {
const run = session.runs.get(segments[4]);
if (!run) return notFound(res);
if (method === "GET") return ok(res, serializeRun(run));
}
if (
segments.length === 6 &&
segments[3] === "runs" &&
segments[5] === "events"
) {
const run = session.runs.get(segments[4]);
if (!run) return notFound(res);
return this.streamEvents(run, req, res, url);
}
if (
segments.length === 6 &&
segments[3] === "runs" &&
segments[5] === "conversation"
) {
const run = session.runs.get(segments[4]);
if (!run) return notFound(res);
return ok(res, {
turns: session.conversation
.filter((t) => !t.runId || t.runId === run.id)
.map(serializeTurn),
});
}
if (
segments.length === 6 &&
segments[3] === "runs" &&
segments[5] === "cancel" &&
method === "POST"
) {
const run = session.runs.get(segments[4]);
if (!run) return notFound(res);
run.status = "cancelled";
run.completedAt = new Date().toISOString();
return ok(res, { ok: true });
}
}
return notFound(res);
} catch (e) {
res.writeHead(500, { "content-type": "application/json" });
res.end(
JSON.stringify({
error: { code: "internal", message: (e as Error).message },
}),
);
}
}
private async createAgent(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
const body = await readJson(req);
const id = this.nextId("agt");
const agent: AgentRecord = {
id,
name: body.name ?? "unnamed",
model: body.model ?? { id: "test-model" },
// Wire format is snake_case (Python idiom); SDK transforms camelCase
// public API to snake_case before sending.
systemPrompt: body.system_prompt,
metadata: body.metadata ?? {},
createdAt: new Date().toISOString(),
sessions: new Map(),
};
this.agents.set(id, agent);
return ok(res, this.serializeAgent(agent));
}
private listAgents(res: ServerResponse): void {
return ok(res, {
items: [...this.agents.values()].map((a) => this.serializeAgent(a)),
});
}
private async createSession(
agent: AgentRecord,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
await readJson(req);
const id = this.nextId("ses");
const vmId = this.nextId("vm");
const session: SessionRecord = {
id,
agentId: agent.id,
status: "ready",
vmId,
createdAt: new Date().toISOString(),
runs: new Map(),
followups: [],
conversation: [],
};
agent.sessions.set(id, session);
return ok(res, serializeSession(session));
}
private async createRun(
session: SessionRecord,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
const body = await readJson(req);
// 409 if any non-terminal run exists.
for (const r of session.runs.values()) {
if (r.status === "queued" || r.status === "running") {
res.writeHead(409, { "content-type": "application/json" });
res.end(
JSON.stringify({
error: { code: "session_busy", message: "run in flight" },
}),
);
return;
}
}
const id = this.nextId("run");
const run: RunRecord = {
id,
sessionId: session.id,
status: "queued",
result: null,
events: [],
startedAt: new Date().toISOString(),
completedAt: null,
sseClients: new Set(),
};
session.runs.set(id, run);
session.conversation.push({
role: "user",
content: body.text ?? "",
runId: id,
createdAt: new Date().toISOString(),
});
return ok(res, serializeRun(run));
}
private streamEvents(
run: RunRecord,
req: IncomingMessage,
res: ServerResponse,
url: URL,
): void {
const startingSeq = Number(url.searchParams.get("starting_seq") ?? -1);
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
// Replay events from startingSeq forward.
const replayFrom =
Number.isFinite(startingSeq) && startingSeq >= 0 ? startingSeq : 0;
for (const event of run.events) {
if (event.seq < replayFrom) continue;
res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
}
if (
run.status === "finished" ||
run.status === "error" ||
run.status === "cancelled"
) {
res.end();
return;
}
run.sseClients.add(res);
const drop = !this.firstStreamDropped && this.options.killSseAfterMs;
if (drop) {
this.firstStreamDropped = true;
setTimeout(() => {
// Force-close socket without flushing any final event.
req.socket.destroy();
run.sseClients.delete(res);
}, this.options.killSseAfterMs);
}
req.on("close", () => {
run.sseClients.delete(res);
});
}
private serializeAgent(agent: AgentRecord) {
// Wire format is snake_case; SDK transforms back to camelCase on receive.
return {
id: agent.id,
name: agent.name,
model: agent.model,
created_at: agent.createdAt,
};
}
}
function serializeSession(s: SessionRecord) {
return {
id: s.id,
agent_id: s.agentId,
status: s.status,
created_at: s.createdAt,
};
}
function serializeRun(r: RunRecord) {
return {
id: r.id,
session_id: r.sessionId,
status: r.status,
result: r.result,
started_at: r.startedAt,
completed_at: r.completedAt,
};
}
function serializeTurn(t: {
role: string;
content: string;
runId?: string;
createdAt: string;
}) {
return {
role: t.role,
content: t.content,
run_id: t.runId,
created_at: t.createdAt,
};
}
function ok(res: ServerResponse, body: unknown): void {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
function notFound(res: ServerResponse): void {
res.writeHead(404, { "content-type": "application/json" });
res.end(
JSON.stringify({ error: { code: "not_found", message: "not found" } }),
);
}
function readJson(req: IncomingMessage): Promise<any> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks).toString();
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch (e) {
reject(e);
}
});
req.on("error", reject);
});
}

View file

@ -0,0 +1,73 @@
/**
* Validation #2 — Round-trip against noop proxy.
*
* Verifies: Agent.create -> agent.createSession -> session.send -> for await
* stream -> session.getRun.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { MockProxy } from "./mock-proxy.js";
describe("noop round-trip", () => {
let proxy: MockProxy;
let baseUrl: string;
beforeEach(async () => {
proxy = new MockProxy();
baseUrl = await proxy.start();
});
afterEach(async () => {
await proxy.stop();
});
it("creates an agent, opens a session, runs a prompt, and streams events to completion", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "round-tripper",
model: { id: "test-model" },
});
expect(agent.id).toMatch(/^agt_/);
expect(agent.name).toBe("round-tripper");
const session = await agent.createSession({
repos: [{ url: "https://github.com/example/repo", startingRef: "main" }],
});
expect(session.id).toMatch(/^ses_/);
expect(session.agentId).toBe(agent.id);
const run = await session.send("hello world");
expect(run.id).toMatch(/^run_/);
expect(run.sessionId).toBe(session.id);
// Drive the run from the proxy side.
const driver = (async () => {
// Tiny delay so the SDK is connected to /events before we emit.
await new Promise((r) => setTimeout(r, 25));
proxy.emit(run.id, "delta", { text: "hi " });
proxy.emit(run.id, "delta", { text: "there" });
proxy.complete(run.id, "hi there");
})();
const collected: { type: string; data: unknown; seq: number }[] = [];
for await (const ev of run.stream()) {
collected.push({ type: ev.type, data: ev.data, seq: ev.seq });
}
await driver;
// Sanity: at least both deltas + done arrived in seq order.
expect(collected.map((e) => e.type)).toContain("delta");
expect(collected.at(-1)?.type).toBe("done");
const seqs = collected.map((e) => e.seq);
expect(seqs).toEqual([...seqs].sort((a, b) => a - b));
// Re-fetch the run by ID and confirm the terminal status.
const fetched = await session.getRun(run.id);
expect(fetched.id).toBe(run.id);
expect(fetched.status).toBe("finished");
expect(fetched.result).toBe("hi there");
});
});

View file

@ -0,0 +1,123 @@
/**
* Spec tests for `Run` (stream/wait/cancel/conversation).
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { MockProxy } from "./mock-proxy.js";
describe("Run", () => {
let proxy: MockProxy;
let baseUrl: string;
beforeEach(async () => {
proxy = new MockProxy();
baseUrl = await proxy.start();
});
afterEach(async () => {
await proxy.stop();
});
it("wait() resolves once the run is in a terminal state", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "r",
model: { id: "m" },
});
const session = await agent.createSession();
const run = await session.send("go");
// Complete asynchronously so wait() actually has to poll.
setTimeout(() => proxy.complete(run.id, "ok"), 50);
const result = await run.wait();
expect(result.id).toBe(run.id);
expect(result.status).toBe("finished");
expect(result.result).toBe("ok");
});
it("stream() yields events in seq order and ends on `done`", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "r",
model: { id: "m" },
});
const session = await agent.createSession();
const run = await session.send("go");
const driver = (async () => {
await new Promise((r) => setTimeout(r, 20));
proxy.emit(run.id, "delta", { text: "1" });
proxy.emit(run.id, "delta", { text: "2" });
proxy.complete(run.id, "12");
})();
const seqs: number[] = [];
const types: string[] = [];
for await (const ev of run.stream()) {
seqs.push(ev.seq);
types.push(ev.type);
}
await driver;
expect(seqs).toEqual([...seqs].sort((a, b) => a - b));
expect(types.at(-1)).toBe("done");
});
it("cancel() flips status server-side and is a no-op once terminal", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "r",
model: { id: "m" },
});
const session = await agent.createSession();
const run = await session.send("go");
await run.cancel();
const proxyRun = proxy.findRun(run.id);
expect(proxyRun?.status).toBe("cancelled");
// Calling again should be a no-op (not throw).
await expect(run.cancel()).resolves.toBeUndefined();
});
it("session.conversation() returns the turn list including the run's user message", async () => {
// `Run.conversation()` was removed — there is no per-run backend endpoint.
// Conversation history is session-scoped; callers use SessionHandle instead.
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "r",
model: { id: "m" },
});
const session = await agent.createSession();
await session.send("hello run");
const turns = await session.conversation();
expect(
turns.some((t) => t.content === "hello run" && t.role === "user"),
).toBe(true);
});
it("stream() respects an AbortSignal", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "r",
model: { id: "m" },
});
const session = await agent.createSession();
const run = await session.send("go");
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 30);
const collected: number[] = [];
for await (const ev of run.stream({ signal: ctrl.signal })) {
collected.push(ev.seq);
}
// Aborts cleanly without throwing.
expect(Array.isArray(collected)).toBe(true);
});
});

View file

@ -0,0 +1,137 @@
/**
* Spec tests for `SessionHandle`.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { MockProxy } from "./mock-proxy.js";
describe("SessionHandle", () => {
let proxy: MockProxy;
let baseUrl: string;
beforeEach(async () => {
proxy = new MockProxy();
baseUrl = await proxy.start();
});
afterEach(async () => {
await proxy.stop();
});
it("send() accepts a string and starts a new run", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "s",
model: { id: "m" },
});
const session = await agent.createSession();
const run = await session.send("hello");
expect(run.id).toMatch(/^run_/);
expect(run.sessionId).toBe(session.id);
expect(run.status).toBe("queued");
});
it("send() accepts a structured input with images", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "s",
model: { id: "m" },
});
const session = await agent.createSession();
const run = await session.send({
text: "describe this",
images: [{ url: "https://example.com/cat.png" }],
});
expect(run.id).toMatch(/^run_/);
});
it("send() returns a 409 if a run is already in flight", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "s",
model: { id: "m" },
});
const session = await agent.createSession();
await session.send("first");
await expect(
session.send({ text: "second", images: [] })
).rejects.toMatchObject({ name: "LiteLLMAgentError", status: 409 });
});
it("getRun() round-trips a run by ID", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "s",
model: { id: "m" },
});
const session = await agent.createSession();
const created = await session.send("yo");
const fetched = await session.getRun(created.id);
expect(fetched.id).toBe(created.id);
expect(fetched.sessionId).toBe(session.id);
});
it("listRuns() returns runs created in this session", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "s",
model: { id: "m" },
});
const session = await agent.createSession();
const r1 = await session.send("one");
proxy.complete(r1.id, "done one");
const r2 = await session.send("two");
proxy.complete(r2.id, "done two");
const listed = await session.listRuns();
const ids = new Set(listed.items.map((r) => r.id));
expect(ids).toEqual(new Set([r1.id, r2.id]));
});
it("conversation() returns turns recorded by the proxy", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "s",
model: { id: "m" },
});
const session = await agent.createSession();
await session.send("first message");
const turns = await session.conversation();
expect(turns.some((t) => t.role === "user" && t.content === "first message")).toBe(
true
);
});
it("delete() flips status to terminated server-side", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "s",
model: { id: "m" },
});
const session = await agent.createSession();
await session.delete();
const proxySession = proxy.findSession(session.id);
expect(proxySession?.status).toBe("terminated");
expect(session.status).toBe("terminated");
});
it("terminate() is an alias for delete()", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "s",
model: { id: "m" },
});
const session = await agent.createSession();
await session.terminate();
expect(proxy.findSession(session.id)?.status).toBe("terminated");
});
});

View file

@ -0,0 +1,99 @@
/**
* Validation #4 — SSE auto-reconnect.
*
* Mid-stream, the mock proxy kills the underlying socket. The SDK should
* reconnect with `?starting_seq=N` transparently and not lose or duplicate
* events.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { MockProxy } from "./mock-proxy.js";
describe("SSE auto-reconnect", () => {
let proxy: MockProxy;
let baseUrl: string;
beforeEach(async () => {
proxy = new MockProxy({ killSseAfterMs: 60 });
baseUrl = await proxy.start();
});
afterEach(async () => {
await proxy.stop();
});
it("transparently reconnects mid-stream and preserves event order without loss or duplication", async () => {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl,
name: "resumer",
model: { id: "test-model" },
});
const session = await agent.createSession();
const run = await session.send("kick off");
// Emit two events before the mock kills the socket, then continue.
const driver = (async () => {
// Wait for the SSE connection to open.
await new Promise((r) => setTimeout(r, 20));
proxy.emit(run.id, "delta", { text: "alpha" });
proxy.emit(run.id, "delta", { text: "beta" });
// Kill happens at ~60ms after first connect.
await new Promise((r) => setTimeout(r, 200));
proxy.emit(run.id, "delta", { text: "gamma" });
proxy.complete(run.id, "alphabetagamma");
})();
const seenSeqs: number[] = [];
const seenTexts: string[] = [];
for await (const ev of run.stream()) {
seenSeqs.push(ev.seq);
if (ev.type === "delta") {
seenTexts.push((ev.data as { text: string }).text);
}
}
await driver;
// Each seq seen exactly once.
expect(new Set(seenSeqs).size).toBe(seenSeqs.length);
// Strictly increasing — no out-of-order replays leaked through.
for (let i = 1; i < seenSeqs.length; i++) {
expect(seenSeqs[i]).toBeGreaterThan(seenSeqs[i - 1]);
}
// All three deltas present (none lost).
expect(seenTexts).toEqual(["alpha", "beta", "gamma"]);
});
it("respects an explicit startingSeq on first connect", async () => {
// Disable mid-stream kill so we test the resume-from-N path only.
const proxy2 = new MockProxy();
const url2 = await proxy2.start();
try {
const agent = await Agent.create({
apiKey: "test-key",
baseUrl: url2,
name: "resume-explicit",
model: { id: "test-model" },
});
const session = await agent.createSession();
const run = await session.send("go");
// Pre-populate events so they exist when stream() opens.
proxy2.emit(run.id, "delta", { text: "0" });
proxy2.emit(run.id, "delta", { text: "1" });
proxy2.emit(run.id, "delta", { text: "2" });
proxy2.complete(run.id, "012");
const seqs: number[] = [];
for await (const ev of run.stream({ startingSeq: 2 })) {
seqs.push(ev.seq);
}
// Should not see 0 or 1 — only seq >= 2 (delta) and the done event (seq 3).
expect(seqs.every((s) => s >= 2)).toBe(true);
expect(seqs).toContain(2);
} finally {
await proxy2.stop();
}
});
});

View file

@ -0,0 +1,179 @@
/**
* Unit tests for the snake_case ↔ camelCase wire transform.
*
* The SDK's public API is camelCase but the LiteLLM proxy speaks snake_case.
* `snakeToCamel` runs over response JSON; `camelToSnake` runs over request
* bodies. Only object keys are rewritten — values pass through unchanged.
*/
import { describe, expect, it } from "vitest";
import { camelToSnake, snakeToCamel } from "../src/client/http.js";
describe("snakeToCamel", () => {
it("renames top-level snake_case keys", () => {
expect(snakeToCamel({ agent_id: "x", created_at: "t" })).toEqual({
agentId: "x",
createdAt: "t",
});
});
it("passes single-word keys through unchanged", () => {
expect(snakeToCamel({ id: "x", type: "delta", data: 1, seq: 7 })).toEqual({
id: "x",
type: "delta",
data: 1,
seq: 7,
});
});
it("recurses into nested objects", () => {
expect(
snakeToCamel({
outer_field: { inner_field: { leaf_key: "v" } },
}),
).toEqual({
outerField: { innerField: { leafKey: "v" } },
});
});
it("recurses into arrays of objects", () => {
expect(
snakeToCamel({
items_list: [
{ item_id: 1, sub_field: "a" },
{ item_id: 2, sub_field: "b" },
],
}),
).toEqual({
itemsList: [
{ itemId: 1, subField: "a" },
{ itemId: 2, subField: "b" },
],
});
});
it("preserves values verbatim (does not transform string values that look snake_case)", () => {
expect(snakeToCamel({ system_prompt: "hello_world" })).toEqual({
systemPrompt: "hello_world",
});
});
it("handles keys with embedded digits", () => {
expect(snakeToCamel({ agent_v2_id: "x" })).toEqual({ agentV2Id: "x" });
});
it("returns primitives unchanged", () => {
expect(snakeToCamel(null)).toBe(null);
expect(snakeToCamel(undefined)).toBe(undefined);
expect(snakeToCamel("snake_string")).toBe("snake_string");
expect(snakeToCamel(42)).toBe(42);
expect(snakeToCamel(true)).toBe(true);
});
it("handles arrays at the top level", () => {
expect(snakeToCamel([{ a_b: 1 }, { a_b: 2 }])).toEqual([
{ aB: 1 },
{ aB: 2 },
]);
});
});
describe("camelToSnake", () => {
it("renames top-level camelCase keys", () => {
expect(camelToSnake({ agentId: "x", createdAt: "t" })).toEqual({
agent_id: "x",
created_at: "t",
});
});
it("passes single-word keys through unchanged", () => {
expect(camelToSnake({ id: "x", type: "delta", data: 1, seq: 7 })).toEqual({
id: "x",
type: "delta",
data: 1,
seq: 7,
});
});
it("recurses into nested objects", () => {
expect(
camelToSnake({
outerField: { innerField: { leafKey: "v" } },
}),
).toEqual({
outer_field: { inner_field: { leaf_key: "v" } },
});
});
it("recurses into arrays of objects", () => {
expect(
camelToSnake({
itemsList: [
{ itemId: 1, subField: "a" },
{ itemId: 2, subField: "b" },
],
}),
).toEqual({
items_list: [
{ item_id: 1, sub_field: "a" },
{ item_id: 2, sub_field: "b" },
],
});
});
it("preserves string values verbatim", () => {
expect(camelToSnake({ systemPrompt: "helloWorld" })).toEqual({
system_prompt: "helloWorld",
});
});
it("handles keys with embedded digits", () => {
expect(camelToSnake({ agentV2Id: "x" })).toEqual({ agent_v2_id: "x" });
});
it("returns primitives unchanged", () => {
expect(camelToSnake(null)).toBe(null);
expect(camelToSnake(undefined)).toBe(undefined);
expect(camelToSnake("camelString")).toBe("camelString");
expect(camelToSnake(42)).toBe(42);
expect(camelToSnake(false)).toBe(false);
});
});
describe("round-trip", () => {
it("camelToSnake → snakeToCamel preserves key names for typical SDK shapes", () => {
const original = {
agentId: "agt_1",
createdAt: "2026-05-06",
systemPrompt: "be helpful",
nestedObject: { innerField: "v", anotherField: 7 },
itemsList: [{ itemId: 1 }, { itemId: 2 }],
// Single-word keys stay stable.
id: "x",
type: "delta",
data: { foo: "bar" },
seq: 0,
status: "ready",
};
const round = snakeToCamel(camelToSnake(original));
expect(round).toEqual(original);
});
it("snakeToCamel → camelToSnake preserves key names for typical wire shapes", () => {
const original = {
agent_id: "agt_1",
created_at: "2026-05-06",
system_prompt: "be helpful",
nested_object: { inner_field: "v", another_field: 7 },
items_list: [{ item_id: 1 }, { item_id: 2 }],
next_cursor: null,
id: "x",
type: "delta",
data: { foo: "bar" },
seq: 0,
status: "ready",
};
const round = camelToSnake(snakeToCamel(original));
expect(round).toEqual(original);
});
});

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests", "examples"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 15_000,
},
});