From 504c90bad71ae1a02823f2727946526f35627a14 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:42 -0700 Subject: [PATCH 01/37] feat(agent-sdk): add gitignore for typescript SDK package --- sdks/typescript-agent-sdk/.gitignore | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 sdks/typescript-agent-sdk/.gitignore diff --git a/sdks/typescript-agent-sdk/.gitignore b/sdks/typescript-agent-sdk/.gitignore new file mode 100644 index 00000000000..dd6e803c7f7 --- /dev/null +++ b/sdks/typescript-agent-sdk/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store From e214328730fe89be58d6316ac6adde20d146a01d Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH 02/37] feat(agent-sdk): add package.json for @litellm/agent-sdk --- sdks/typescript-agent-sdk/package.json | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 sdks/typescript-agent-sdk/package.json diff --git a/sdks/typescript-agent-sdk/package.json b/sdks/typescript-agent-sdk/package.json new file mode 100644 index 00000000000..bd80f6ce0c3 --- /dev/null +++ b/sdks/typescript-agent-sdk/package.json @@ -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": "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" + ] +} From 8ffd32036fc460c7dcf4fa413c7c5869673baba4 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH 03/37] feat(agent-sdk): add tsconfig --- sdks/typescript-agent-sdk/tsconfig.json | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tsconfig.json diff --git a/sdks/typescript-agent-sdk/tsconfig.json b/sdks/typescript-agent-sdk/tsconfig.json new file mode 100644 index 00000000000..d59b8332941 --- /dev/null +++ b/sdks/typescript-agent-sdk/tsconfig.json @@ -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"] +} From 6fa5f15782d32451f2b19e8f0c03472f930c6efa Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH 04/37] feat(agent-sdk): add vitest config --- sdks/typescript-agent-sdk/vitest.config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 sdks/typescript-agent-sdk/vitest.config.ts diff --git a/sdks/typescript-agent-sdk/vitest.config.ts b/sdks/typescript-agent-sdk/vitest.config.ts new file mode 100644 index 00000000000..faf9dfd8867 --- /dev/null +++ b/sdks/typescript-agent-sdk/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + testTimeout: 15_000, + }, +}); From f3965f652a61e17db81748a7e10b1b18ce1ab042 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH 05/37] feat(agent-sdk): add public types and LiteLLMAgentError --- sdks/typescript-agent-sdk/src/types.ts | 160 +++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 sdks/typescript-agent-sdk/src/types.ts diff --git a/sdks/typescript-agent-sdk/src/types.ts b/sdks/typescript-agent-sdk/src/types.ts new file mode 100644 index 00000000000..057ae52f6a5 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/types.ts @@ -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 { + 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; +} + +/** 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; + /** Free-form tags / metadata persisted on the session. */ + metadata?: Record; +} + +export type SessionStatus = + | "pending" + | "running" + | "idle" + | "terminated" + | "failed"; + +export type RunStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled"; + +/** 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; + } +} From 8e20ed5ef8edb6181ca00867fc612b9a4adf3de1 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH 06/37] feat(agent-sdk): add fetch wrapper with auth + retry --- sdks/typescript-agent-sdk/src/client/http.ts | 193 +++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 sdks/typescript-agent-sdk/src/client/http.ts diff --git a/sdks/typescript-agent-sdk/src/client/http.ts b/sdks/typescript-agent-sdk/src/client/http.ts new file mode 100644 index 00000000000..6d491d930ea --- /dev/null +++ b/sdks/typescript-agent-sdk/src/client/http.ts @@ -0,0 +1,193 @@ +/** + * Thin fetch wrapper for the LiteLLM proxy. + * + * Responsibilities: + * - Resolve apiKey + baseUrl from explicit args or env + * - Inject `Authorization: Bearer ` + * - Retry retryable errors (5xx, 429) with exponential backoff + * - Normalize errors to `LiteLLMAgentError` + */ + +import { ClientOptions, LiteLLMAgentError } from "../types.js"; + +const DEFAULT_BASE_URL = "https://api.litellm.ai"; +const DEFAULT_TIMEOUT_MS = 60_000; +const DEFAULT_MAX_RETRIES = 3; + +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; + headers?: Record; + /** 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 { + const url = buildUrl(client.baseUrl, opts.path, opts.query); + const headers: Record = { + 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(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( + client: ResolvedClient, + opts: RequestOpts +): Promise { + const res = await request(client, opts); + if (res.status === 204) return undefined as unknown as T; + return (await res.json()) as T; +} + +function buildUrl( + baseUrl: string, + path: string, + query?: Record +): 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 { + 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.max(0, n * 1000); + } + } + // 250ms, 500ms, 1s, 2s, ... + return 250 * Math.pow(2, attempt); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function withTimeout(p: Promise, ms: number): Promise { + if (!ms || ms <= 0) return p; + let timer: ReturnType | undefined; + const timeout = new Promise((_, 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); + } +} From a7c94022a5a5a3f20342a675cb3f6b30d6b2673f Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH 07/37] feat(agent-sdk): add SSE client with Last-Event-ID resume --- sdks/typescript-agent-sdk/src/client/sse.ts | 179 ++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 sdks/typescript-agent-sdk/src/client/sse.ts diff --git a/sdks/typescript-agent-sdk/src/client/sse.ts b/sdks/typescript-agent-sdk/src/client/sse.ts new file mode 100644 index 00000000000..8f104e7d034 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/client/sse.ts @@ -0,0 +1,179 @@ +/** + * 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 /v1/sessions/{sid}/runs/{rid}/events`. + * + * Resume contract: + * - On every event, we record `seq`. + * - On reconnect, we send both `Last-Event-ID: ` (per the SSE spec) + * and `?starting_seq=` (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 { + 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: `/v1/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; + 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; + 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; + 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; + 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, + signal?: AbortSignal +): AsyncIterable { + 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((r) => { + state.resolve = r; + }); + state.resolve = null; + } + } finally { + await pump.catch(() => {}); + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function backoffMs(attempt: number): number { + return Math.min(5_000, 250 * Math.pow(2, attempt)); +} From 25ffa570c1ff7d2768f291db0844bab33374227a Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH 08/37] feat(agent-sdk): add Agent + AgentHandle classes --- sdks/typescript-agent-sdk/src/agent.ts | 135 +++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 sdks/typescript-agent-sdk/src/agent.ts diff --git a/sdks/typescript-agent-sdk/src/agent.ts b/sdks/typescript-agent-sdk/src/agent.ts new file mode 100644 index 00000000000..a33e3437ab1 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/agent.ts @@ -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 { + const info = await requestJson(this._client, { + method: "POST", + path: `/v1/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 { + const info = await requestJson(this._client, { + method: "GET", + path: `/v1/agents/${encodeURIComponent(this.id)}/sessions/${encodeURIComponent(sessionId)}`, + }); + return new SessionHandle(info, this._client); + } + + /** List sessions running under this agent. */ + async listSessions(options: ListOptions = {}): Promise> { + const data = await requestJson<{ items: SessionInfo[]; nextCursor?: string }>( + this._client, + { + method: "GET", + path: `/v1/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): Promise { + await requestJson(this._client, { + method: "PATCH", + path: `/v1/agents/${encodeURIComponent(this.id)}`, + body: stripClientOptions(patch), + }); + } + + /** Delete the agent definition. Cascades to its sessions. */ + async delete(): Promise { + await requestJson(this._client, { + method: "DELETE", + path: `/v1/agents/${encodeURIComponent(this.id)}`, + }); + } +} + +export class Agent { + /** Create a new agent definition. */ + static async create(options: AgentCreateOptions): Promise { + const client = resolveClient(options); + const info = await requestJson(client, { + method: "POST", + path: "/v1/agents", + body: stripClientOptions(options), + }); + return new AgentHandle(info, client); + } + + /** Fetch an existing agent by ID. */ + static async get(agentId: string, options: ClientOptions = {}): Promise { + const client = resolveClient(options); + const info = await requestJson(client, { + method: "GET", + path: `/v1/agents/${encodeURIComponent(agentId)}`, + }); + return new AgentHandle(info, client); + } + + /** List agents accessible to this API key. */ + static async list( + options: ClientOptions & ListOptions = {} + ): Promise> { + const client = resolveClient(options); + const data = await requestJson<{ items: AgentInfo[]; nextCursor?: string }>( + client, + { + method: "GET", + path: "/v1/agents", + query: { limit: options.limit, cursor: options.cursor }, + } + ); + return { items: data.items ?? [], nextCursor: data.nextCursor }; + } +} + +function stripClientOptions>( + obj: T +): Omit { + const { apiKey, baseUrl, fetch, timeoutMs, maxRetries, ...rest } = obj as AgentCreateOptions; + void apiKey; + void baseUrl; + void fetch; + void timeoutMs; + void maxRetries; + return rest as Omit; +} From 33eb658f8be6e50d30d6bc4721be3ed96065e215 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH 09/37] feat(agent-sdk): add SessionHandle with asyncDispose --- sdks/typescript-agent-sdk/src/session.ts | 133 +++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 sdks/typescript-agent-sdk/src/session.ts diff --git a/sdks/typescript-agent-sdk/src/session.ts b/sdks/typescript-agent-sdk/src/session.ts new file mode 100644 index 00000000000..d15c675011e --- /dev/null +++ b/sdks/typescript-agent-sdk/src/session.ts @@ -0,0 +1,133 @@ +/** + * 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 { + const body = normalizeSendInput(input); + const info = await requestJson(this._client, { + method: "POST", + path: `/v1/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 { + await requestJson(this._client, { + method: "POST", + path: `/v1/sessions/${encodeURIComponent(this.id)}/followup`, + body: { message }, + }); + } + + /** Fetch a single run by ID. */ + async getRun(runId: string): Promise { + const info = await requestJson(this._client, { + method: "GET", + path: `/v1/sessions/${encodeURIComponent(this.id)}/runs/${encodeURIComponent(runId)}`, + }); + return new Run(info, this._client); + } + + /** List runs belonging to this session. */ + async listRuns(options: ListOptions = {}): Promise> { + const data = await requestJson<{ items: RunInfo[]; nextCursor?: string }>( + this._client, + { + method: "GET", + path: `/v1/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 { + const data = await requestJson<{ turns: ConversationTurn[] }>(this._client, { + method: "GET", + path: `/v1/sessions/${encodeURIComponent(this.id)}/conversation`, + }); + return data.turns ?? []; + } + + /** Tear down the VM and delete the session. */ + async delete(): Promise { + await requestJson(this._client, { + method: "DELETE", + path: `/v1/sessions/${encodeURIComponent(this.id)}`, + }); + this._status = "terminated"; + } + + /** Alias of `delete()`. */ + async terminate(): Promise { + await this.delete(); + } + + /** + * Enables `await using session = await agent.createSession(...)`. + * Calls DELETE on scope exit. + */ + async [Symbol.asyncDispose](): Promise { + 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 ?? [] }; +} From 82c03fd68a93011023cede8290d07201c48d9dc5 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:53 -0700 Subject: [PATCH 10/37] feat(agent-sdk): add Run class with stream/wait/cancel --- sdks/typescript-agent-sdk/src/run.ts | 102 +++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 sdks/typescript-agent-sdk/src/run.ts diff --git a/sdks/typescript-agent-sdk/src/run.ts b/sdks/typescript-agent-sdk/src/run.ts new file mode 100644 index 00000000000..753e17559db --- /dev/null +++ b/sdks/typescript-agent-sdk/src/run.ts @@ -0,0 +1,102 @@ +/** + * Run — a single execution of an agent inside a session. + * + * Lifecycle: queued → running → completed | failed | cancelled. + */ + +import { resolveClient, requestJson, type ResolvedClient } from "./client/http.js"; +import { streamRunEvents } from "./client/sse.js"; +import { + type ConversationTurn, + type RunEvent, + type RunInfo, + type RunResult, + type RunStatus, +} from "./types.js"; + +const TERMINAL_STATES: RunStatus[] = ["completed", "failed", "cancelled"]; + +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 { + return streamRunEvents(this._client, this.sessionId, this.id, opts); + } + + /** Block until the run reaches a terminal status. */ + async wait(): Promise { + while (!TERMINAL_STATES.includes(this._status)) { + const info = await requestJson(this._client, { + method: "GET", + path: `/v1/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}`, + }); + this._status = info.status; + this._result = info.result; + this._git = info.git; + if (TERMINAL_STATES.includes(this._status)) break; + await sleep(500); + } + return { + id: this.id, + status: this._status, + result: this._result, + git: this._git, + }; + } + + /** Snapshot of the conversation up through this run. */ + async conversation(): Promise { + const data = await requestJson<{ turns: ConversationTurn[] }>(this._client, { + method: "GET", + path: `/v1/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/conversation`, + }); + return data.turns ?? []; + } + + /** Cancel a running run; no-op if already terminal. */ + async cancel(): Promise { + if (TERMINAL_STATES.includes(this._status)) return; + await requestJson(this._client, { + method: "POST", + path: `/v1/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/cancel`, + }); + this._status = "cancelled"; + } +} + +/** Internal helper used by SessionHandle / Agent to build a Run from a wire response. */ +export function runFromInfo(info: RunInfo, options: { apiKey?: string; baseUrl?: string }): Run { + return new Run(info, resolveClient(options)); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} From 7ffe2bbdaeb095b23ba599db1deb8e395a8cbc3c Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:53 -0700 Subject: [PATCH 11/37] feat(agent-sdk): add public surface re-exports --- sdks/typescript-agent-sdk/src/index.ts | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 sdks/typescript-agent-sdk/src/index.ts diff --git a/sdks/typescript-agent-sdk/src/index.ts b/sdks/typescript-agent-sdk/src/index.ts new file mode 100644 index 00000000000..c4700231a78 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/index.ts @@ -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"; From 49e30b020d9361c0aa86457ad0a16e89935c4f74 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:53 -0700 Subject: [PATCH 12/37] test(agent-sdk): add in-memory mock proxy for tests --- sdks/typescript-agent-sdk/tests/mock-proxy.ts | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/mock-proxy.ts diff --git a/sdks/typescript-agent-sdk/tests/mock-proxy.ts b/sdks/typescript-agent-sdk/tests/mock-proxy.ts new file mode 100644 index 00000000000..c9b4d3c9926 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/mock-proxy.ts @@ -0,0 +1,432 @@ +/** + * 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; + createdAt: string; + sessions: Map; +} + +interface SessionRecord { + id: string; + agentId: string; + status: "pending" | "running" | "idle" | "terminated" | "failed"; + vmId: string; + createdAt: string; + runs: Map; + followups: string[]; + conversation: { role: string; content: string; runId?: string; createdAt: string }[]; +} + +interface RunRecord { + id: string; + sessionId: string; + status: "queued" | "running" | "completed" | "failed" | "cancelled"; + 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; +} + +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 = 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 { + await new Promise((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 { + // 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((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 = "completed"; + 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 { + 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] !== "v1") { + return notFound(res); + } + // /v1/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)); + } + // /v1/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); + session.followups.push(body.message); + session.conversation.push({ + role: "user", + content: body.message, + createdAt: new Date().toISOString(), + }); + return ok(res, { ok: true }); + } + if (segments.length === 4 && segments[3] === "conversation" && method === "GET") { + return ok(res, { turns: session.conversation }); + } + 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) }); + } + 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 { + const body = await readJson(req); + const id = this.nextId("agt"); + const agent: AgentRecord = { + id, + name: body.name ?? "unnamed", + model: body.model ?? { id: "test-model" }, + systemPrompt: body.systemPrompt, + 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 { + await readJson(req); + const id = this.nextId("ses"); + const vmId = this.nextId("vm"); + const session: SessionRecord = { + id, + agentId: agent.id, + status: "idle", + 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 { + 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 === "completed" || run.status === "failed" || 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) { + return { + id: agent.id, + name: agent.name, + model: agent.model, + createdAt: agent.createdAt, + }; + } +} + +function serializeSession(s: SessionRecord) { + return { + id: s.id, + agentId: s.agentId, + status: s.status, + createdAt: s.createdAt, + }; +} + +function serializeRun(r: RunRecord) { + return { + id: r.id, + sessionId: r.sessionId, + status: r.status, + result: r.result, + startedAt: r.startedAt, + completedAt: r.completedAt, + }; +} + +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 { + 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); + }); +} From 1a9fbc63e691719fe7bb9c78c9fcef68eab16de1 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:21 -0700 Subject: [PATCH 13/37] fix: target /v2/ agent-session API --- sdks/typescript-agent-sdk/src/agent.ts | 16 ++++++++-------- sdks/typescript-agent-sdk/src/client/sse.ts | 4 ++-- sdks/typescript-agent-sdk/src/run.ts | 6 +++--- sdks/typescript-agent-sdk/src/session.ts | 12 ++++++------ sdks/typescript-agent-sdk/tests/mock-proxy.ts | 6 +++--- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/sdks/typescript-agent-sdk/src/agent.ts b/sdks/typescript-agent-sdk/src/agent.ts index a33e3437ab1..e6d0a383722 100644 --- a/sdks/typescript-agent-sdk/src/agent.ts +++ b/sdks/typescript-agent-sdk/src/agent.ts @@ -33,7 +33,7 @@ export class AgentHandle { async createSession(options: CreateSessionOptions = {}): Promise { const info = await requestJson(this._client, { method: "POST", - path: `/v1/agents/${encodeURIComponent(this.id)}/sessions`, + path: `/v2/agents/${encodeURIComponent(this.id)}/sessions`, body: { repos: options.repos ?? [], envVars: options.envVars ?? {}, @@ -47,7 +47,7 @@ export class AgentHandle { async getSession(sessionId: string): Promise { const info = await requestJson(this._client, { method: "GET", - path: `/v1/agents/${encodeURIComponent(this.id)}/sessions/${encodeURIComponent(sessionId)}`, + path: `/v2/agents/${encodeURIComponent(this.id)}/sessions/${encodeURIComponent(sessionId)}`, }); return new SessionHandle(info, this._client); } @@ -58,7 +58,7 @@ export class AgentHandle { this._client, { method: "GET", - path: `/v1/agents/${encodeURIComponent(this.id)}/sessions`, + path: `/v2/agents/${encodeURIComponent(this.id)}/sessions`, query: { limit: options.limit, cursor: options.cursor }, } ); @@ -69,7 +69,7 @@ export class AgentHandle { async update(patch: Partial): Promise { await requestJson(this._client, { method: "PATCH", - path: `/v1/agents/${encodeURIComponent(this.id)}`, + path: `/v2/agents/${encodeURIComponent(this.id)}`, body: stripClientOptions(patch), }); } @@ -78,7 +78,7 @@ export class AgentHandle { async delete(): Promise { await requestJson(this._client, { method: "DELETE", - path: `/v1/agents/${encodeURIComponent(this.id)}`, + path: `/v2/agents/${encodeURIComponent(this.id)}`, }); } } @@ -89,7 +89,7 @@ export class Agent { const client = resolveClient(options); const info = await requestJson(client, { method: "POST", - path: "/v1/agents", + path: "/v2/agents", body: stripClientOptions(options), }); return new AgentHandle(info, client); @@ -100,7 +100,7 @@ export class Agent { const client = resolveClient(options); const info = await requestJson(client, { method: "GET", - path: `/v1/agents/${encodeURIComponent(agentId)}`, + path: `/v2/agents/${encodeURIComponent(agentId)}`, }); return new AgentHandle(info, client); } @@ -114,7 +114,7 @@ export class Agent { client, { method: "GET", - path: "/v1/agents", + path: "/v2/agents", query: { limit: options.limit, cursor: options.cursor }, } ); diff --git a/sdks/typescript-agent-sdk/src/client/sse.ts b/sdks/typescript-agent-sdk/src/client/sse.ts index 8f104e7d034..9680b43cce8 100644 --- a/sdks/typescript-agent-sdk/src/client/sse.ts +++ b/sdks/typescript-agent-sdk/src/client/sse.ts @@ -17,7 +17,7 @@ export interface StreamRunOptions { } /** - * Stream events from `GET /v1/sessions/{sid}/runs/{rid}/events`. + * Stream events from `GET /v2/sessions/{sid}/runs/{rid}/events`. * * Resume contract: * - On every event, we record `seq`. @@ -41,7 +41,7 @@ export async function* streamRunEvents( try { res = await request(client, { method: "GET", - path: `/v1/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}/events`, + path: `/v2/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}/events`, query: startingSeq !== undefined ? { starting_seq: startingSeq } : undefined, headers: startingSeq !== undefined diff --git a/sdks/typescript-agent-sdk/src/run.ts b/sdks/typescript-agent-sdk/src/run.ts index 753e17559db..9ab9b775f2c 100644 --- a/sdks/typescript-agent-sdk/src/run.ts +++ b/sdks/typescript-agent-sdk/src/run.ts @@ -56,7 +56,7 @@ export class Run { while (!TERMINAL_STATES.includes(this._status)) { const info = await requestJson(this._client, { method: "GET", - path: `/v1/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}`, + path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}`, }); this._status = info.status; this._result = info.result; @@ -76,7 +76,7 @@ export class Run { async conversation(): Promise { const data = await requestJson<{ turns: ConversationTurn[] }>(this._client, { method: "GET", - path: `/v1/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/conversation`, + path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/conversation`, }); return data.turns ?? []; } @@ -86,7 +86,7 @@ export class Run { if (TERMINAL_STATES.includes(this._status)) return; await requestJson(this._client, { method: "POST", - path: `/v1/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/cancel`, + path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/cancel`, }); this._status = "cancelled"; } diff --git a/sdks/typescript-agent-sdk/src/session.ts b/sdks/typescript-agent-sdk/src/session.ts index d15c675011e..6f69a99783a 100644 --- a/sdks/typescript-agent-sdk/src/session.ts +++ b/sdks/typescript-agent-sdk/src/session.ts @@ -46,7 +46,7 @@ export class SessionHandle { const body = normalizeSendInput(input); const info = await requestJson(this._client, { method: "POST", - path: `/v1/sessions/${encodeURIComponent(this.id)}/runs`, + path: `/v2/sessions/${encodeURIComponent(this.id)}/runs`, body, }); return new Run(info, this._client); @@ -56,7 +56,7 @@ export class SessionHandle { async followup(message: string): Promise { await requestJson(this._client, { method: "POST", - path: `/v1/sessions/${encodeURIComponent(this.id)}/followup`, + path: `/v2/sessions/${encodeURIComponent(this.id)}/followup`, body: { message }, }); } @@ -65,7 +65,7 @@ export class SessionHandle { async getRun(runId: string): Promise { const info = await requestJson(this._client, { method: "GET", - path: `/v1/sessions/${encodeURIComponent(this.id)}/runs/${encodeURIComponent(runId)}`, + path: `/v2/sessions/${encodeURIComponent(this.id)}/runs/${encodeURIComponent(runId)}`, }); return new Run(info, this._client); } @@ -76,7 +76,7 @@ export class SessionHandle { this._client, { method: "GET", - path: `/v1/sessions/${encodeURIComponent(this.id)}/runs`, + path: `/v2/sessions/${encodeURIComponent(this.id)}/runs`, query: { limit: options.limit, cursor: options.cursor }, } ); @@ -90,7 +90,7 @@ export class SessionHandle { async conversation(): Promise { const data = await requestJson<{ turns: ConversationTurn[] }>(this._client, { method: "GET", - path: `/v1/sessions/${encodeURIComponent(this.id)}/conversation`, + path: `/v2/sessions/${encodeURIComponent(this.id)}/conversation`, }); return data.turns ?? []; } @@ -99,7 +99,7 @@ export class SessionHandle { async delete(): Promise { await requestJson(this._client, { method: "DELETE", - path: `/v1/sessions/${encodeURIComponent(this.id)}`, + path: `/v2/sessions/${encodeURIComponent(this.id)}`, }); this._status = "terminated"; } diff --git a/sdks/typescript-agent-sdk/tests/mock-proxy.ts b/sdks/typescript-agent-sdk/tests/mock-proxy.ts index c9b4d3c9926..9efb5ae57b0 100644 --- a/sdks/typescript-agent-sdk/tests/mock-proxy.ts +++ b/sdks/typescript-agent-sdk/tests/mock-proxy.ts @@ -157,10 +157,10 @@ export class MockProxy { const segments = url.pathname.split("/").filter(Boolean); try { - if (segments[0] !== "v1") { + if (segments[0] !== "v2") { return notFound(res); } - // /v1/agents + // /v2/agents if (segments.length === 2 && segments[1] === "agents") { if (method === "POST") return this.createAgent(req, res); if (method === "GET") return this.listAgents(res); @@ -189,7 +189,7 @@ export class MockProxy { if (!session) return notFound(res); if (method === "GET") return ok(res, serializeSession(session)); } - // /v1/sessions/{sid}/... + // /v2/sessions/{sid}/... if (segments[1] === "sessions" && segments.length >= 3) { const session = this.findSession(segments[2]); if (!session) return notFound(res); From f68b984fe259326e716bc76560bc9de577422a26 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:43 -0700 Subject: [PATCH 14/37] chore(agent-sdk): tweak package.json repository URL --- sdks/typescript-agent-sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/typescript-agent-sdk/package.json b/sdks/typescript-agent-sdk/package.json index bd80f6ce0c3..58da0fd37d8 100644 --- a/sdks/typescript-agent-sdk/package.json +++ b/sdks/typescript-agent-sdk/package.json @@ -7,7 +7,7 @@ "homepage": "https://github.com/BerriAI/litellm/tree/main/sdks/typescript-agent-sdk", "repository": { "type": "git", - "url": "https://github.com/BerriAI/litellm.git", + "url": "git+https://github.com/BerriAI/litellm.git", "directory": "sdks/typescript-agent-sdk" }, "main": "dist/index.js", From 2f0e1d741561acc3ddc94a09f2f119c6b972e868 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:48 -0700 Subject: [PATCH 15/37] docs(agent-sdk): add README with quickstart and API reference --- sdks/typescript-agent-sdk/README.md | 218 ++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 sdks/typescript-agent-sdk/README.md diff --git a/sdks/typescript-agent-sdk/README.md b/sdks/typescript-agent-sdk/README.md new file mode 100644 index 00000000000..833f23621e3 --- /dev/null +++ b/sdks/typescript-agent-sdk/README.md @@ -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: ` (per the SSE spec) and +`?starting_seq=` (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 `. + +## 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_` | 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` +- `Agent.get(agentId: string, options: ClientOptions): Promise` +- `Agent.list(options?: ClientOptions & ListOptions): Promise>` + +### `AgentHandle` + +- `agent.id: string` +- `agent.name: string` +- `agent.createSession(options?: CreateSessionOptions): Promise` +- `agent.getSession(sessionId: string): Promise` +- `agent.listSessions(options?: ListOptions): Promise>` +- `agent.update(patch: Partial): Promise` +- `agent.delete(): Promise` — cascades to sessions + +### `SessionHandle` + +- `session.id: string` +- `session.agentId: string` +- `session.status: SessionStatus` +- `session.send(input: string | { text; images? }): Promise` — 409 if a run is in flight +- `session.followup(message: string): Promise` — queues into the active run +- `session.getRun(runId: string): Promise` +- `session.listRuns(options?: ListOptions): Promise>` +- `session.conversation(): Promise` +- `session.delete(): Promise` / `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` +- `run.wait(): Promise` +- `run.cancel(): Promise` +- `run.conversation(): Promise` + +## Examples + +See [`examples/basic.ts`](./examples/basic.ts) and +[`examples/followup.ts`](./examples/followup.ts). + +## License + +MIT — © BerriAI From 61e8a07314d4f77c81793052d63fe0adf9d34c49 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:48 -0700 Subject: [PATCH 16/37] feat(agent-sdk): add basic usage example --- sdks/typescript-agent-sdk/examples/basic.ts | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 sdks/typescript-agent-sdk/examples/basic.ts diff --git a/sdks/typescript-agent-sdk/examples/basic.ts b/sdks/typescript-agent-sdk/examples/basic.ts new file mode 100644 index 00000000000..06ff9b668bc --- /dev/null +++ b/sdks/typescript-agent-sdk/examples/basic.ts @@ -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 { + 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); +}); From 93954fc5a06638bc2c9f53bbcb822d7055c5534d Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:48 -0700 Subject: [PATCH 17/37] feat(agent-sdk): add followup multi-turn example --- .../typescript-agent-sdk/examples/followup.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 sdks/typescript-agent-sdk/examples/followup.ts diff --git a/sdks/typescript-agent-sdk/examples/followup.ts b/sdks/typescript-agent-sdk/examples/followup.ts new file mode 100644 index 00000000000..d56623b28e2 --- /dev/null +++ b/sdks/typescript-agent-sdk/examples/followup.ts @@ -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 { + 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); +}); From e31d20e93d74ba7ec28713da3697ee02353f8129 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:56 -0700 Subject: [PATCH 18/37] test(agent-sdk): add Agent class tests --- sdks/typescript-agent-sdk/tests/agent.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/agent.test.ts diff --git a/sdks/typescript-agent-sdk/tests/agent.test.ts b/sdks/typescript-agent-sdk/tests/agent.test.ts new file mode 100644 index 00000000000..43a406978fe --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/agent.test.ts @@ -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"); + }); +}); From 4aae87bd08c33195370ece13cb42a81295a5a3b2 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:56 -0700 Subject: [PATCH 19/37] test(agent-sdk): add SessionHandle tests --- .../tests/session.test.ts | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/session.test.ts diff --git a/sdks/typescript-agent-sdk/tests/session.test.ts b/sdks/typescript-agent-sdk/tests/session.test.ts new file mode 100644 index 00000000000..4ca4d1d8efc --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/session.test.ts @@ -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"); + }); +}); From e3683ec9562d4c174782af992dd48a75f29ef95f Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:56 -0700 Subject: [PATCH 20/37] test(agent-sdk): add Run class tests --- sdks/typescript-agent-sdk/tests/run.test.ts | 121 ++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/run.test.ts diff --git a/sdks/typescript-agent-sdk/tests/run.test.ts b/sdks/typescript-agent-sdk/tests/run.test.ts new file mode 100644 index 00000000000..a175d6c3b5c --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/run.test.ts @@ -0,0 +1,121 @@ +/** + * 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("completed"); + 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("conversation() returns the turn list scoped to this run", 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("hello run"); + const turns = await run.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); + }); +}); From 4926177cfd3fc44b2256b431c6a0b331dac8a5f6 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:56 -0700 Subject: [PATCH 21/37] test(agent-sdk): add noop-roundtrip end-to-end test --- .../tests/noop-roundtrip.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts diff --git a/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts b/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts new file mode 100644 index 00000000000..db565cd993d --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts @@ -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("completed"); + expect(fetched.result).toBe("hi there"); + }); +}); From b4b6c109e768f19e8af4b97f71bdeb617402f174 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:56 -0700 Subject: [PATCH 22/37] test(agent-sdk): add agent-reuse multi-session test --- .../tests/agent-reuse.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/agent-reuse.test.ts diff --git a/sdks/typescript-agent-sdk/tests/agent-reuse.test.ts b/sdks/typescript-agent-sdk/tests/agent-reuse.test.ts new file mode 100644 index 00000000000..36736b8ada4 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/agent-reuse.test.ts @@ -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)); + }); +}); From bd1539285a2ff17777425ada0af6736d0840beb0 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:56 -0700 Subject: [PATCH 23/37] test(agent-sdk): add SSE auto-reconnect test --- .../tests/sse-reconnect.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts diff --git a/sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts b/sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts new file mode 100644 index 00000000000..07c55db5024 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts @@ -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(); + } + }); +}); From 66527b6beb4d893a3a711792708304c58f03020f Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:56 -0700 Subject: [PATCH 24/37] test(agent-sdk): add session.followup queueing test --- .../tests/followup.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/followup.test.ts diff --git a/sdks/typescript-agent-sdk/tests/followup.test.ts b/sdks/typescript-agent-sdk/tests/followup.test.ts new file mode 100644 index 00000000000..44dc11ad842 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/followup.test.ts @@ -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); + }); +}); From a371832337b5768225e68c0a8ed153c0cc95b28a Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:59:56 -0700 Subject: [PATCH 25/37] test(agent-sdk): add Symbol.asyncDispose teardown test --- .../tests/dispose.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/dispose.test.ts diff --git a/sdks/typescript-agent-sdk/tests/dispose.test.ts b/sdks/typescript-agent-sdk/tests/dispose.test.ts new file mode 100644 index 00000000000..31e0b77f4a5 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/dispose.test.ts @@ -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(); + }); +}); From db579303fdcf5b416551e7eeedbf09c1fddddf5e Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 15:00:19 -0700 Subject: [PATCH 26/37] chore(agent-sdk): ignore package-lock.json (use pnpm) --- sdks/typescript-agent-sdk/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/sdks/typescript-agent-sdk/.gitignore b/sdks/typescript-agent-sdk/.gitignore index dd6e803c7f7..5d3055a0da9 100644 --- a/sdks/typescript-agent-sdk/.gitignore +++ b/sdks/typescript-agent-sdk/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.log .DS_Store +package-lock.json From 67cad5ea60dbd79567f5bd80d99530a825ea74ca Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 6 May 2026 15:28:35 -0700 Subject: [PATCH 27/37] fix(agent-sdk): add timeout/abort support to Run.wait() and drop runFromInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: wait() polled indefinitely with no escape hatch — a stuck or partitioned server would hang the caller forever. Now accepts { signal, timeoutMs, pollMs } and throws LiteLLMAgentError with codes wait_aborted / wait_timeout. Forwards the signal to the underlying requestJson call and to the inter-poll sleep. Greptile P2: runFromInfo was exported but had no internal callers (agent.ts and session.ts both build Run directly). Removed to shrink the surface and avoid leaking resolveClient as a construction detail. --- sdks/typescript-agent-sdk/src/run.ts | 74 ++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/sdks/typescript-agent-sdk/src/run.ts b/sdks/typescript-agent-sdk/src/run.ts index 9ab9b775f2c..9e5b5835601 100644 --- a/sdks/typescript-agent-sdk/src/run.ts +++ b/sdks/typescript-agent-sdk/src/run.ts @@ -4,9 +4,10 @@ * Lifecycle: queued → running → completed | failed | cancelled. */ -import { resolveClient, requestJson, type ResolvedClient } from "./client/http.js"; +import { requestJson, type ResolvedClient } from "./client/http.js"; import { streamRunEvents } from "./client/sse.js"; import { + LiteLLMAgentError, type ConversationTurn, type RunEvent, type RunInfo, @@ -15,6 +16,7 @@ import { } from "./types.js"; const TERMINAL_STATES: RunStatus[] = ["completed", "failed", "cancelled"]; +const DEFAULT_WAIT_POLL_MS = 500; export class Run { readonly id: string; @@ -47,22 +49,50 @@ export class Run { } /** Open an SSE stream of events for this run. */ - stream(opts: { startingSeq?: number; signal?: AbortSignal } = {}): AsyncIterable { + stream( + opts: { startingSeq?: number; signal?: AbortSignal } = {}, + ): AsyncIterable { return streamRunEvents(this._client, this.sessionId, this.id, opts); } - /** Block until the run reaches a terminal status. */ - async wait(): Promise { + /** + * 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 { + 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(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(500); + await sleep(pollMs, opts.signal); } return { id: this.id, @@ -74,10 +104,13 @@ export class Run { /** Snapshot of the conversation up through this run. */ async conversation(): Promise { - const data = await requestJson<{ turns: ConversationTurn[] }>(this._client, { - method: "GET", - path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/conversation`, - }); + const data = await requestJson<{ turns: ConversationTurn[] }>( + this._client, + { + method: "GET", + path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/conversation`, + }, + ); return data.turns ?? []; } @@ -92,11 +125,20 @@ export class Run { } } -/** Internal helper used by SessionHandle / Agent to build a Run from a wire response. */ -export function runFromInfo(info: RunInfo, options: { apiKey?: string; baseUrl?: string }): Run { - return new Run(info, resolveClient(options)); -} - -function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); +function sleep(ms: number, signal?: AbortSignal): Promise { + 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 }); + }); } From 642ba6463599bee1c264f298f1f243739d157261 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 6 May 2026 15:28:42 -0700 Subject: [PATCH 28/37] fix(agent-sdk): reset SSE reconnect counter after a successful connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: the reconnects counter accumulated across the entire stream lifetime — for a long-running stream with several transient drops over hours, the budget would be exhausted even though every individual reconnect succeeded. Now the counter tracks *consecutive* failures: once a connection delivers at least one new event, the counter resets to zero on the next drop, so only a sustained outage trips sse_reconnect_exhausted. --- sdks/typescript-agent-sdk/src/client/sse.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/sdks/typescript-agent-sdk/src/client/sse.ts b/sdks/typescript-agent-sdk/src/client/sse.ts index 9680b43cce8..5dd73af6926 100644 --- a/sdks/typescript-agent-sdk/src/client/sse.ts +++ b/sdks/typescript-agent-sdk/src/client/sse.ts @@ -29,7 +29,7 @@ export async function* streamRunEvents( client: ResolvedClient, sessionId: string, runId: string, - opts: StreamRunOptions = {} + opts: StreamRunOptions = {}, ): AsyncIterable { const maxReconnects = opts.maxReconnects ?? 5; let lastSeq = opts.startingSeq !== undefined ? opts.startingSeq - 1 : -1; @@ -42,11 +42,10 @@ export async function* streamRunEvents( res = await request(client, { method: "GET", path: `/v2/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}/events`, - query: startingSeq !== undefined ? { starting_seq: startingSeq } : undefined, + query: + startingSeq !== undefined ? { starting_seq: startingSeq } : undefined, headers: - startingSeq !== undefined - ? { "Last-Event-ID": String(lastSeq) } - : {}, + startingSeq !== undefined ? { "Last-Event-ID": String(lastSeq) } : {}, stream: true, signal: opts.signal, }); @@ -65,6 +64,7 @@ export async function* streamRunEvents( } let droppedMidStream = false; + let sawProgressOnThisConnection = false; try { for await (const event of parseSSE(res.body, opts.signal)) { const parsed = decodeEvent(event); @@ -74,6 +74,7 @@ export async function* streamRunEvents( continue; } lastSeq = parsed.seq; + sawProgressOnThisConnection = true; yield parsed; if (parsed.type === "done" || parsed.type === "error") { return; @@ -87,10 +88,16 @@ export async function* streamRunEvents( } 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" } + { code: "sse_reconnect_exhausted" }, ); } reconnects++; @@ -117,7 +124,7 @@ function decodeEvent(msg: EventSourceMessage): RunEvent | null { async function* parseSSE( body: ReadableStream, - signal?: AbortSignal + signal?: AbortSignal, ): AsyncIterable { const queue: EventSourceMessage[] = []; const state: { resolve: (() => void) | null; done: boolean } = { From 6f3895b07c9debe13c4b6976095c0d4f011d9ef6 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 6 May 2026 15:28:48 -0700 Subject: [PATCH 29/37] fix(agent-sdk): cap honored Retry-After at 60s Greptile P2: a misbehaving or adversarial server returning 'Retry-After: 9999999' could stall the SDK indefinitely. Cap the honored delay at MAX_RETRY_AFTER_MS (60s). --- sdks/typescript-agent-sdk/src/client/http.ts | 29 ++++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/sdks/typescript-agent-sdk/src/client/http.ts b/sdks/typescript-agent-sdk/src/client/http.ts index 6d491d930ea..44e49c3ea27 100644 --- a/sdks/typescript-agent-sdk/src/client/http.ts +++ b/sdks/typescript-agent-sdk/src/client/http.ts @@ -13,6 +13,9 @@ import { ClientOptions, LiteLLMAgentError } from "../types.js"; 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; @@ -30,7 +33,7 @@ export function resolveClient(options: ClientOptions = {}): ResolvedClient { if (!apiKey) { throw new LiteLLMAgentError( "Missing apiKey. Pass `apiKey` explicitly or set LITELLM_API_KEY in the environment.", - { code: "missing_api_key" } + { code: "missing_api_key" }, ); } @@ -58,7 +61,7 @@ export interface RequestOpts { export async function request( client: ResolvedClient, - opts: RequestOpts + opts: RequestOpts, ): Promise { const url = buildUrl(client.baseUrl, opts.path, opts.query); const headers: Record = { @@ -107,7 +110,7 @@ export async function request( export async function requestJson( client: ResolvedClient, - opts: RequestOpts + opts: RequestOpts, ): Promise { const res = await request(client, opts); if (res.status === 204) return undefined as unknown as T; @@ -117,12 +120,9 @@ export async function requestJson( function buildUrl( baseUrl: string, path: string, - query?: Record + query?: Record, ): string { - const url = new URL( - path.startsWith("/") ? path : `/${path}`, - baseUrl + "/" - ); + const url = new URL(path.startsWith("/") ? path : `/${path}`, baseUrl + "/"); if (query) { for (const [k, v] of Object.entries(query)) { if (v === undefined) continue; @@ -139,7 +139,10 @@ async function toAgentError(res: Response): Promise { 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 }; + 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; @@ -159,7 +162,9 @@ function backoffMs(attempt: number, res?: Response): number { const ra = res.headers.get("retry-after"); if (ra) { const n = Number(ra); - if (!Number.isNaN(n)) return Math.max(0, n * 1000); + if (!Number.isNaN(n)) { + return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, n * 1000)); + } } } // 250ms, 500ms, 1s, 2s, ... @@ -180,9 +185,9 @@ async function withTimeout(p: Promise, ms: number): Promise { new LiteLLMAgentError(`Request timed out after ${ms}ms`, { code: "timeout", retryable: true, - }) + }), ), - ms + ms, ); }); try { From 595f66089984f83efe541d77a4ddf2154c9a7ec4 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 16:10:10 -0700 Subject: [PATCH 30/37] fix(agent-sdk): align SessionStatus and RunStatus enums with backend wire values Backend (LIT-2890) emits provisioning/ready/busy/error/terminated for session status and queued/running/finished/cancelled/error for run status. Update the SDK type aliases so callers compare against the actual values the proxy returns over the wire. --- sdks/typescript-agent-sdk/src/types.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/sdks/typescript-agent-sdk/src/types.ts b/sdks/typescript-agent-sdk/src/types.ts index 057ae52f6a5..4c88a8180ad 100644 --- a/sdks/typescript-agent-sdk/src/types.ts +++ b/sdks/typescript-agent-sdk/src/types.ts @@ -66,18 +66,18 @@ export interface CreateSessionOptions { } export type SessionStatus = - | "pending" - | "running" - | "idle" - | "terminated" - | "failed"; + | "provisioning" + | "ready" + | "busy" + | "error" + | "terminated"; export type RunStatus = | "queued" | "running" - | "completed" - | "failed" - | "cancelled"; + | "finished" + | "cancelled" + | "error"; /** Lightweight info returned by list endpoints. */ export interface AgentInfo { @@ -149,7 +149,7 @@ export class LiteLLMAgentError extends Error { message: string, options: { code: string; status?: number; retryable?: boolean } = { code: "unknown", - } + }, ) { super(message); this.name = "LiteLLMAgentError"; From 68e58bf69bd79095a173f0aeb81dfe1a509c73d6 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 16:10:16 -0700 Subject: [PATCH 31/37] feat(agent-sdk): add snake_case <-> camelCase transform layer to HTTP client Backend speaks snake_case (Python idiom) while the SDK's public TS API is camelCase. Add recursive snakeToCamel and camelToSnake helpers and wire them into the HTTP layer so request bodies are camel->snake before JSON.stringify and response JSON is snake->camel before being returned to callers. Single-word keys like id/type/data/seq/status pass through unchanged in both directions, and non-object values are not touched. --- sdks/typescript-agent-sdk/src/client/http.ts | 68 +++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/sdks/typescript-agent-sdk/src/client/http.ts b/sdks/typescript-agent-sdk/src/client/http.ts index 44e49c3ea27..6a5359da16f 100644 --- a/sdks/typescript-agent-sdk/src/client/http.ts +++ b/sdks/typescript-agent-sdk/src/client/http.ts @@ -6,10 +6,70 @@ * - Inject `Authorization: Bearer ` * - 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 = {}; + 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 = {}; + 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 { + 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; @@ -76,7 +136,10 @@ export async function request( const init: RequestInit = { method: opts.method ?? "GET", headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + body: + opts.body !== undefined + ? JSON.stringify(camelToSnake(opts.body)) + : undefined, signal: opts.signal, }; @@ -114,7 +177,8 @@ export async function requestJson( ): Promise { const res = await request(client, opts); if (res.status === 204) return undefined as unknown as T; - return (await res.json()) as T; + const parsed = await res.json(); + return snakeToCamel(parsed) as T; } function buildUrl( From 3ffaad8b1a8ae5202b2b3a68d2c552e6c87cdc0d Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 16:10:22 -0700 Subject: [PATCH 32/37] fix(agent-sdk): send {prompt: {text}} as followup body to match backend FollowupCreate The backend's followup endpoint expects a FollowupCreate payload with a nested prompt object ({prompt: {text: ...}}), not a flat {message: ...}. Public method signature followup(message: string) is unchanged - only the wire body changes. --- sdks/typescript-agent-sdk/src/session.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/sdks/typescript-agent-sdk/src/session.ts b/sdks/typescript-agent-sdk/src/session.ts index 6f69a99783a..dfbf0a719f3 100644 --- a/sdks/typescript-agent-sdk/src/session.ts +++ b/sdks/typescript-agent-sdk/src/session.ts @@ -54,10 +54,11 @@ export class SessionHandle { /** Queue a follow-up message into the active run. */ async followup(message: string): Promise { + // Wire shape matches backend `FollowupCreate`: {prompt: {text: "..."}}. await requestJson(this._client, { method: "POST", path: `/v2/sessions/${encodeURIComponent(this.id)}/followup`, - body: { message }, + body: { prompt: { text: message } }, }); } @@ -78,7 +79,7 @@ export class SessionHandle { 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)), @@ -88,10 +89,13 @@ export class SessionHandle { /** Snapshot of the full conversation across runs. */ async conversation(): Promise { - const data = await requestJson<{ turns: ConversationTurn[] }>(this._client, { - method: "GET", - path: `/v2/sessions/${encodeURIComponent(this.id)}/conversation`, - }); + const data = await requestJson<{ turns: ConversationTurn[] }>( + this._client, + { + method: "GET", + path: `/v2/sessions/${encodeURIComponent(this.id)}/conversation`, + }, + ); return data.turns ?? []; } From b65d494c39210bc6beda8c77e71a5bf9976f677f Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 16:10:28 -0700 Subject: [PATCH 33/37] refactor(agent-sdk): drop Run.conversation() and update terminal states There is no per-run conversation endpoint on the backend; conversation history is session-scoped. Callers should use SessionHandle.conversation() instead. Also update TERMINAL_STATES to the new RunStatus values (finished/cancelled/error). --- sdks/typescript-agent-sdk/src/run.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/sdks/typescript-agent-sdk/src/run.ts b/sdks/typescript-agent-sdk/src/run.ts index 9e5b5835601..a402e3aaa38 100644 --- a/sdks/typescript-agent-sdk/src/run.ts +++ b/sdks/typescript-agent-sdk/src/run.ts @@ -8,14 +8,13 @@ import { requestJson, type ResolvedClient } from "./client/http.js"; import { streamRunEvents } from "./client/sse.js"; import { LiteLLMAgentError, - type ConversationTurn, type RunEvent, type RunInfo, type RunResult, type RunStatus, } from "./types.js"; -const TERMINAL_STATES: RunStatus[] = ["completed", "failed", "cancelled"]; +const TERMINAL_STATES: RunStatus[] = ["finished", "cancelled", "error"]; const DEFAULT_WAIT_POLL_MS = 500; export class Run { @@ -102,17 +101,9 @@ export class Run { }; } - /** Snapshot of the conversation up through this run. */ - async conversation(): Promise { - const data = await requestJson<{ turns: ConversationTurn[] }>( - this._client, - { - method: "GET", - path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/conversation`, - }, - ); - return data.turns ?? []; - } + // 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 { From f68bd102304defe2f490c1e208cf041f01a06085 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 16:10:35 -0700 Subject: [PATCH 34/37] test(agent-sdk): mirror backend snake_case wire format and new status enums in mock proxy Mock proxy now serializes responses using snake_case keys (agent_id, created_at, system_prompt, run_id, etc.) and reads request bodies as snake_case so it matches the real backend that the SDK now talks to via the new transform layer. Also update the status string literals to the new SessionStatus and RunStatus values, and read the followup body as {prompt: {text}}. --- sdks/typescript-agent-sdk/tests/mock-proxy.ts | 171 ++++++++++++++---- 1 file changed, 131 insertions(+), 40 deletions(-) diff --git a/sdks/typescript-agent-sdk/tests/mock-proxy.ts b/sdks/typescript-agent-sdk/tests/mock-proxy.ts index 9efb5ae57b0..bc3674fe168 100644 --- a/sdks/typescript-agent-sdk/tests/mock-proxy.ts +++ b/sdks/typescript-agent-sdk/tests/mock-proxy.ts @@ -23,18 +23,23 @@ interface AgentRecord { interface SessionRecord { id: string; agentId: string; - status: "pending" | "running" | "idle" | "terminated" | "failed"; + status: "provisioning" | "ready" | "busy" | "error" | "terminated"; vmId: string; createdAt: string; runs: Map; followups: string[]; - conversation: { role: string; content: string; runId?: string; createdAt: string }[]; + conversation: { + role: string; + content: string; + runId?: string; + createdAt: string; + }[]; } interface RunRecord { id: string; sessionId: string; - status: "queued" | "running" | "completed" | "failed" | "cancelled"; + status: "queued" | "running" | "finished" | "cancelled" | "error"; result: string | null; events: { seq: number; type: string; data: unknown }[]; startedAt: string | null; @@ -68,7 +73,9 @@ export class MockProxy { } async start(): Promise { - await new Promise((resolve) => this.server.listen(0, "127.0.0.1", resolve)); + await new Promise((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}`; } @@ -84,7 +91,7 @@ export class MockProxy { } } await new Promise((resolve, reject) => - this.server.close((err) => (err ? reject(err) : resolve())) + this.server.close((err) => (err ? reject(err) : resolve())), ); } @@ -124,7 +131,7 @@ export class MockProxy { complete(runId: string, result: string): void { const run = this.findRun(runId); if (!run) return; - run.status = "completed"; + run.status = "finished"; run.result = result; run.completedAt = new Date().toISOString(); this.emit(runId, "done", { result }); @@ -137,18 +144,28 @@ export class MockProxy { return `${prefix}_${this.idCounter}`; } - private async handle(req: IncomingMessage, res: ServerResponse): Promise { + private async handle( + req: IncomingMessage, + res: ServerResponse, + ): Promise { 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" } })); + 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" } })); + res.end( + JSON.stringify({ error: { code: "unauthorized", message: "bad key" } }), + ); return; } @@ -174,7 +191,11 @@ export class MockProxy { return ok(res, { ok: true }); } } - if (segments.length === 4 && segments[1] === "agents" && segments[3] === "sessions") { + 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); @@ -183,7 +204,11 @@ export class MockProxy { items: [...agent.sessions.values()].map(serializeSession), }); } - if (segments.length === 5 && segments[1] === "agents" && segments[3] === "sessions") { + 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); @@ -200,43 +225,78 @@ export class MockProxy { return ok(res, { ok: true }); } } - if (segments.length === 4 && segments[3] === "runs" && method === "POST") { + if ( + segments.length === 4 && + segments[3] === "runs" && + method === "POST" + ) { return this.createRun(session, req, res); } - if (segments.length === 4 && segments[3] === "runs" && method === "GET") { + 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") { + if ( + segments.length === 4 && + segments[3] === "followup" && + method === "POST" + ) { const body = await readJson(req); - session.followups.push(body.message); + // Wire format: {prompt: {text: "..."}} per FollowupCreate. + const text = body?.prompt?.text ?? ""; + session.followups.push(text); session.conversation.push({ role: "user", - content: body.message, + 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 }); + 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") { + 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") { + 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) }); + 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") { + 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"; @@ -248,19 +308,26 @@ export class MockProxy { } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end( - JSON.stringify({ error: { code: "internal", message: (e as Error).message } }) + JSON.stringify({ + error: { code: "internal", message: (e as Error).message }, + }), ); } } - private async createAgent(req: IncomingMessage, res: ServerResponse): Promise { + private async createAgent( + req: IncomingMessage, + res: ServerResponse, + ): Promise { const body = await readJson(req); const id = this.nextId("agt"); const agent: AgentRecord = { id, name: body.name ?? "unnamed", model: body.model ?? { id: "test-model" }, - systemPrompt: body.systemPrompt, + // 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(), @@ -278,7 +345,7 @@ export class MockProxy { private async createSession( agent: AgentRecord, req: IncomingMessage, - res: ServerResponse + res: ServerResponse, ): Promise { await readJson(req); const id = this.nextId("ses"); @@ -286,7 +353,7 @@ export class MockProxy { const session: SessionRecord = { id, agentId: agent.id, - status: "idle", + status: "ready", vmId, createdAt: new Date().toISOString(), runs: new Map(), @@ -300,7 +367,7 @@ export class MockProxy { private async createRun( session: SessionRecord, req: IncomingMessage, - res: ServerResponse + res: ServerResponse, ): Promise { const body = await readJson(req); // 409 if any non-terminal run exists. @@ -308,7 +375,9 @@ export class MockProxy { 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" } }) + JSON.stringify({ + error: { code: "session_busy", message: "run in flight" }, + }), ); return; } @@ -338,7 +407,7 @@ export class MockProxy { run: RunRecord, req: IncomingMessage, res: ServerResponse, - url: URL + url: URL, ): void { const startingSeq = Number(url.searchParams.get("starting_seq") ?? -1); res.writeHead(200, { @@ -348,13 +417,18 @@ export class MockProxy { }); // Replay events from startingSeq forward. - const replayFrom = Number.isFinite(startingSeq) && startingSeq >= 0 ? startingSeq : 0; + 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 === "completed" || run.status === "failed" || run.status === "cancelled") { + if ( + run.status === "finished" || + run.status === "error" || + run.status === "cancelled" + ) { res.end(); return; } @@ -375,11 +449,12 @@ export class MockProxy { } 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, - createdAt: agent.createdAt, + created_at: agent.createdAt, }; } } @@ -387,20 +462,34 @@ export class MockProxy { function serializeSession(s: SessionRecord) { return { id: s.id, - agentId: s.agentId, + agent_id: s.agentId, status: s.status, - createdAt: s.createdAt, + created_at: s.createdAt, }; } function serializeRun(r: RunRecord) { return { id: r.id, - sessionId: r.sessionId, + session_id: r.sessionId, status: r.status, result: r.result, - startedAt: r.startedAt, - completedAt: r.completedAt, + 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, }; } @@ -411,7 +500,9 @@ function ok(res: ServerResponse, body: unknown): void { function notFound(res: ServerResponse): void { res.writeHead(404, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: { code: "not_found", message: "not found" } })); + res.end( + JSON.stringify({ error: { code: "not_found", message: "not found" } }), + ); } function readJson(req: IncomingMessage): Promise { From 290808fa648f6baac043ac3c91fc1b139a29b1f6 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 16:10:39 -0700 Subject: [PATCH 35/37] test(agent-sdk): expect 'finished' instead of 'completed' for terminal RunStatus --- sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts b/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts index db565cd993d..175f5dc5c1b 100644 --- a/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts +++ b/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts @@ -67,7 +67,7 @@ describe("noop round-trip", () => { // 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("completed"); + expect(fetched.status).toBe("finished"); expect(fetched.result).toBe("hi there"); }); }); From fb05cf0200e347256b0421b5c0e8d60a7f6c2584 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 16:10:44 -0700 Subject: [PATCH 36/37] test(agent-sdk): switch removed Run.conversation() test to session.conversation() Also update the wait() test to expect the new 'finished' terminal status. --- sdks/typescript-agent-sdk/tests/run.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/sdks/typescript-agent-sdk/tests/run.test.ts b/sdks/typescript-agent-sdk/tests/run.test.ts index a175d6c3b5c..00e25567910 100644 --- a/sdks/typescript-agent-sdk/tests/run.test.ts +++ b/sdks/typescript-agent-sdk/tests/run.test.ts @@ -34,7 +34,7 @@ describe("Run", () => { const result = await run.wait(); expect(result.id).toBe(run.id); - expect(result.status).toBe("completed"); + expect(result.status).toBe("finished"); expect(result.result).toBe("ok"); }); @@ -83,7 +83,9 @@ describe("Run", () => { await expect(run.cancel()).resolves.toBeUndefined(); }); - it("conversation() returns the turn list scoped to this run", async () => { + 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, @@ -91,11 +93,11 @@ describe("Run", () => { model: { id: "m" }, }); const session = await agent.createSession(); - const run = await session.send("hello run"); - const turns = await run.conversation(); - expect(turns.some((t) => t.content === "hello run" && t.role === "user")).toBe( - true - ); + 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 () => { From 1b0ca087d40ce4e07f1eff44bfa9d7122fa95b54 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 16:10:50 -0700 Subject: [PATCH 37/37] test(agent-sdk): cover snake_case <-> camelCase transform helpers Round-trip + edge-case coverage: digits in keys (agent_v2_id), nested objects, arrays of objects, top-level arrays, single-word key passthrough, and primitive passthrough. --- .../tests/wire-transform.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 sdks/typescript-agent-sdk/tests/wire-transform.test.ts diff --git a/sdks/typescript-agent-sdk/tests/wire-transform.test.ts b/sdks/typescript-agent-sdk/tests/wire-transform.test.ts new file mode 100644 index 00000000000..d3e61d7c6a3 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/wire-transform.test.ts @@ -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); + }); +});