mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
merge: integrate D (LIT-2880) — TS SDK with snake↔camel transform
This commit is contained in:
commit
84cfae0220
24 changed files with 2904 additions and 0 deletions
5
sdks/typescript-agent-sdk/.gitignore
vendored
Normal file
5
sdks/typescript-agent-sdk/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
218
sdks/typescript-agent-sdk/README.md
Normal file
218
sdks/typescript-agent-sdk/README.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# @litellm/agent-sdk
|
||||
|
||||
TypeScript SDK for the LiteLLM Agents API. Mirrors the Cursor SDK's style but
|
||||
fixes its conflation of agent definition and VM session by exposing a clean
|
||||
3-level hierarchy:
|
||||
|
||||
```
|
||||
Agent — definition (model, system prompt, tools)
|
||||
└─ Session — VM sandbox running under an agent
|
||||
└─ Run — single execution within a session
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm i @litellm/agent-sdk
|
||||
# or
|
||||
pnpm add @litellm/agent-sdk
|
||||
```
|
||||
|
||||
Requires Node 18+ (Node 20+ recommended for `await using` support).
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { Agent } from "@litellm/agent-sdk";
|
||||
|
||||
// 1. Define an agent (do this once at app startup).
|
||||
const agent = await Agent.create({
|
||||
apiKey: process.env.LITELLM_API_KEY,
|
||||
baseUrl: process.env.LITELLM_BASE_URL, // e.g. http://localhost:4000
|
||||
name: "shin-cursor",
|
||||
model: { id: "claude-4.6-sonnet" },
|
||||
systemPrompt: "You are a senior engineer fixing GitHub issues.",
|
||||
});
|
||||
|
||||
// 2. Spin up a session per workflow (per Slack issue, per ticket, etc).
|
||||
const session = await agent.createSession({
|
||||
repos: [{ url: "https://github.com/example/repo", startingRef: "main" }],
|
||||
envVars: { GITHUB_TOKEN: process.env.GITHUB_TOKEN! },
|
||||
});
|
||||
|
||||
// 3. Kick off a run and stream the events.
|
||||
const run = await session.send("Fix the bug described in issue #123.");
|
||||
for await (const event of run.stream()) {
|
||||
console.log(event.type, event.data);
|
||||
}
|
||||
|
||||
// 4. Wait for the final result.
|
||||
const result = await run.wait();
|
||||
console.log(result.status, result.result);
|
||||
|
||||
// 5. Tear down the VM when you're done.
|
||||
await session.delete();
|
||||
```
|
||||
|
||||
## Multi-turn (followups)
|
||||
|
||||
```ts
|
||||
const run = await session.send("Implement the feature.");
|
||||
const stream = run.stream();
|
||||
|
||||
// In another async context, queue a follow-up message into the active run.
|
||||
await session.followup("Also handle the empty-input edge case, please.");
|
||||
|
||||
for await (const event of stream) {
|
||||
// events from the original run continue to flow, including any
|
||||
// tool calls / deltas resulting from the follow-up.
|
||||
}
|
||||
```
|
||||
|
||||
## Resumable streams
|
||||
|
||||
Every event carries a monotonic `seq`. Pass `startingSeq` to reconnect from a
|
||||
specific point — useful if your process restarts mid-stream:
|
||||
|
||||
```ts
|
||||
let lastSeq = 0;
|
||||
for await (const event of run.stream({ startingSeq: lastSeq + 1 })) {
|
||||
lastSeq = event.seq;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The SDK also auto-reconnects internally when the underlying socket drops. It
|
||||
sends both `Last-Event-ID: <seq>` (per the SSE spec) and
|
||||
`?starting_seq=<seq+1>` (LiteLLM-specific); the proxy honors whichever it
|
||||
understands and replays from there. Already-seen events are de-duped.
|
||||
|
||||
## `await using` (Symbol.asyncDispose)
|
||||
|
||||
If you're on TypeScript 5.2+ / Node 20+, you can scope a session to a block:
|
||||
|
||||
```ts
|
||||
{
|
||||
await using session = await agent.createSession();
|
||||
const run = await session.send("...");
|
||||
await run.wait();
|
||||
} // session is DELETEd automatically here
|
||||
```
|
||||
|
||||
`session.terminate()` is a manual alias for `delete()` for older runtimes.
|
||||
|
||||
## Reusing a single agent across sessions
|
||||
|
||||
```ts
|
||||
const agent = await Agent.create({ /* ... */ });
|
||||
|
||||
// Same agent definition, three independent VM sessions.
|
||||
const [s1, s2, s3] = await Promise.all([
|
||||
agent.createSession(),
|
||||
agent.createSession(),
|
||||
agent.createSession(),
|
||||
]);
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```ts
|
||||
const agent = await Agent.create({
|
||||
apiKey: "sk-...", // or set LITELLM_API_KEY in the environment
|
||||
baseUrl: "https://api.litellm.ai", // default; override for your deployment
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
`apiKey` falls back to `process.env.LITELLM_API_KEY` if not provided. The SDK
|
||||
sends it as `Authorization: Bearer <apiKey>`.
|
||||
|
||||
## Errors
|
||||
|
||||
All errors are instances of `LiteLLMAgentError`:
|
||||
|
||||
```ts
|
||||
import { LiteLLMAgentError } from "@litellm/agent-sdk";
|
||||
|
||||
try {
|
||||
await agent.createSession();
|
||||
} catch (e) {
|
||||
if (e instanceof LiteLLMAgentError) {
|
||||
console.error(e.code, e.status, e.retryable);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Code | Meaning |
|
||||
| ----------------- | --------------------------------------------- |
|
||||
| `missing_api_key` | No `apiKey` provided and `LITELLM_API_KEY` unset |
|
||||
| `not_found` | Agent / session / run does not exist |
|
||||
| `session_busy` | `send()` called while a run is in flight (409) |
|
||||
| `rate_limited` | 429 — SDK retried `maxRetries` times |
|
||||
| `timeout` | Request exceeded `timeoutMs` |
|
||||
| `http_<status>` | Generic HTTP error fallback |
|
||||
|
||||
5xx and 429 are retried automatically with exponential backoff. Configure
|
||||
`maxRetries` and `timeoutMs` per call via `ClientOptions`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Default | Purpose |
|
||||
| ------------ | ----------------------------- | ------------------------------------ |
|
||||
| `apiKey` | `process.env.LITELLM_API_KEY` | Bearer token for the proxy |
|
||||
| `baseUrl` | `https://api.litellm.ai` | Proxy URL |
|
||||
| `fetch` | `globalThis.fetch` | Override for tests / custom transport |
|
||||
| `timeoutMs` | `60_000` | Per-request timeout |
|
||||
| `maxRetries` | `3` | Retries on 5xx/429/network errors |
|
||||
|
||||
## API reference
|
||||
|
||||
### `Agent`
|
||||
|
||||
- `Agent.create(options: AgentCreateOptions): Promise<AgentHandle>`
|
||||
- `Agent.get(agentId: string, options: ClientOptions): Promise<AgentHandle>`
|
||||
- `Agent.list(options?: ClientOptions & ListOptions): Promise<ListResult<AgentInfo>>`
|
||||
|
||||
### `AgentHandle`
|
||||
|
||||
- `agent.id: string`
|
||||
- `agent.name: string`
|
||||
- `agent.createSession(options?: CreateSessionOptions): Promise<SessionHandle>`
|
||||
- `agent.getSession(sessionId: string): Promise<SessionHandle>`
|
||||
- `agent.listSessions(options?: ListOptions): Promise<ListResult<SessionInfo>>`
|
||||
- `agent.update(patch: Partial<AgentCreateOptions>): Promise<void>`
|
||||
- `agent.delete(): Promise<void>` — cascades to sessions
|
||||
|
||||
### `SessionHandle`
|
||||
|
||||
- `session.id: string`
|
||||
- `session.agentId: string`
|
||||
- `session.status: SessionStatus`
|
||||
- `session.send(input: string | { text; images? }): Promise<Run>` — 409 if a run is in flight
|
||||
- `session.followup(message: string): Promise<void>` — queues into the active run
|
||||
- `session.getRun(runId: string): Promise<Run>`
|
||||
- `session.listRuns(options?: ListOptions): Promise<ListResult<Run>>`
|
||||
- `session.conversation(): Promise<ConversationTurn[]>`
|
||||
- `session.delete(): Promise<void>` / `session.terminate()`
|
||||
- `session[Symbol.asyncDispose]()` — for `await using`
|
||||
|
||||
### `Run`
|
||||
|
||||
- `run.id: string`
|
||||
- `run.sessionId: string`
|
||||
- `run.status: RunStatus`
|
||||
- `run.result: string | null`
|
||||
- `run.git?: { branches: { branch; prUrl }[] }`
|
||||
- `run.stream(opts?: { startingSeq?, signal? }): AsyncIterable<RunEvent>`
|
||||
- `run.wait(): Promise<RunResult>`
|
||||
- `run.cancel(): Promise<void>`
|
||||
- `run.conversation(): Promise<ConversationTurn[]>`
|
||||
|
||||
## Examples
|
||||
|
||||
See [`examples/basic.ts`](./examples/basic.ts) and
|
||||
[`examples/followup.ts`](./examples/followup.ts).
|
||||
|
||||
## License
|
||||
|
||||
MIT — © BerriAI
|
||||
42
sdks/typescript-agent-sdk/examples/basic.ts
Normal file
42
sdks/typescript-agent-sdk/examples/basic.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Basic example — Agent.create -> session.send -> stream -> wait.
|
||||
*
|
||||
* Run with:
|
||||
* LITELLM_API_KEY=sk-... LITELLM_BASE_URL=http://localhost:4000 \
|
||||
* npx tsx examples/basic.ts
|
||||
*/
|
||||
|
||||
import { Agent } from "../src/index.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const agent = await Agent.create({
|
||||
apiKey: process.env.LITELLM_API_KEY,
|
||||
baseUrl: process.env.LITELLM_BASE_URL,
|
||||
name: "basic-example",
|
||||
model: { id: "claude-4.6-sonnet" },
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
});
|
||||
|
||||
console.log("agent created:", agent.id);
|
||||
|
||||
const session = await agent.createSession();
|
||||
console.log("session created:", session.id);
|
||||
|
||||
const run = await session.send("Say hi in one short sentence.");
|
||||
console.log("run started:", run.id);
|
||||
|
||||
for await (const event of run.stream()) {
|
||||
console.log(`[${event.seq}] ${event.type}`, event.data);
|
||||
}
|
||||
|
||||
const result = await run.wait();
|
||||
console.log("final status:", result.status);
|
||||
console.log("result:", result.result);
|
||||
|
||||
await session.delete();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
59
sdks/typescript-agent-sdk/examples/followup.ts
Normal file
59
sdks/typescript-agent-sdk/examples/followup.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Multi-turn example — send a long-running prompt and queue a follow-up
|
||||
* message into the same run.
|
||||
*
|
||||
* Run with:
|
||||
* LITELLM_API_KEY=sk-... LITELLM_BASE_URL=http://localhost:4000 \
|
||||
* npx tsx examples/followup.ts
|
||||
*/
|
||||
|
||||
import { Agent } from "../src/index.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const agent = await Agent.create({
|
||||
apiKey: process.env.LITELLM_API_KEY,
|
||||
baseUrl: process.env.LITELLM_BASE_URL,
|
||||
name: "followup-example",
|
||||
model: { id: "claude-4.6-sonnet" },
|
||||
});
|
||||
|
||||
// `await using` automatically tears down the VM at scope exit.
|
||||
await using session = await agent.createSession({
|
||||
repos: [
|
||||
{ url: "https://github.com/example/repo", startingRef: "main" },
|
||||
],
|
||||
});
|
||||
|
||||
const run = await session.send(
|
||||
"Implement the new search endpoint described in issue #42."
|
||||
);
|
||||
|
||||
// While the run is in flight, queue a clarification.
|
||||
setTimeout(() => {
|
||||
session
|
||||
.followup("Also handle the empty-input case and add a test for it.")
|
||||
.catch((e) => console.error("followup failed:", e));
|
||||
}, 2_000);
|
||||
|
||||
for await (const event of run.stream()) {
|
||||
if (event.type === "delta") {
|
||||
const data = event.data as { text?: string };
|
||||
if (data.text) process.stdout.write(data.text);
|
||||
} else {
|
||||
console.log(`\n[${event.seq}] ${event.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await run.wait();
|
||||
console.log("\n--- run finished:", result.status, "---");
|
||||
if (result.git?.branches?.length) {
|
||||
for (const b of result.git.branches) {
|
||||
console.log("branch:", b.branch, b.prUrl ?? "(no PR)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
59
sdks/typescript-agent-sdk/package.json
Normal file
59
sdks/typescript-agent-sdk/package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "@litellm/agent-sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "TypeScript SDK for LiteLLM Agents — Agent → Session → Run hierarchy",
|
||||
"license": "MIT",
|
||||
"author": "BerriAI",
|
||||
"homepage": "https://github.com/BerriAI/litellm/tree/main/sdks/typescript-agent-sdk",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/BerriAI/litellm.git",
|
||||
"directory": "sdks/typescript-agent-sdk"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"examples/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"msw": "^2.4.0",
|
||||
"prettier": "^3.3.0",
|
||||
"tsup": "^8.2.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"litellm",
|
||||
"agent",
|
||||
"sdk",
|
||||
"ai",
|
||||
"llm",
|
||||
"session",
|
||||
"cursor"
|
||||
]
|
||||
}
|
||||
135
sdks/typescript-agent-sdk/src/agent.ts
Normal file
135
sdks/typescript-agent-sdk/src/agent.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Agent — the *definition* of an agent (model, system prompt, tools).
|
||||
*
|
||||
* An Agent is not a VM. Use `agent.createSession()` to spin up a VM that runs
|
||||
* under this agent definition.
|
||||
*/
|
||||
|
||||
import { requestJson, resolveClient, type ResolvedClient } from "./client/http.js";
|
||||
import { SessionHandle } from "./session.js";
|
||||
import {
|
||||
type AgentCreateOptions,
|
||||
type AgentInfo,
|
||||
type ClientOptions,
|
||||
type CreateSessionOptions,
|
||||
type ListOptions,
|
||||
type ListResult,
|
||||
type SessionInfo,
|
||||
} from "./types.js";
|
||||
|
||||
export class AgentHandle {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
private readonly _client: ResolvedClient;
|
||||
|
||||
constructor(info: AgentInfo, client: ResolvedClient) {
|
||||
this.id = info.id;
|
||||
this.name = info.name;
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
/** Create a new session running under this agent. */
|
||||
async createSession(options: CreateSessionOptions = {}): Promise<SessionHandle> {
|
||||
const info = await requestJson<SessionInfo>(this._client, {
|
||||
method: "POST",
|
||||
path: `/v2/agents/${encodeURIComponent(this.id)}/sessions`,
|
||||
body: {
|
||||
repos: options.repos ?? [],
|
||||
envVars: options.envVars ?? {},
|
||||
metadata: options.metadata ?? {},
|
||||
},
|
||||
});
|
||||
return new SessionHandle(info, this._client);
|
||||
}
|
||||
|
||||
/** Fetch a session by ID under this agent. */
|
||||
async getSession(sessionId: string): Promise<SessionHandle> {
|
||||
const info = await requestJson<SessionInfo>(this._client, {
|
||||
method: "GET",
|
||||
path: `/v2/agents/${encodeURIComponent(this.id)}/sessions/${encodeURIComponent(sessionId)}`,
|
||||
});
|
||||
return new SessionHandle(info, this._client);
|
||||
}
|
||||
|
||||
/** List sessions running under this agent. */
|
||||
async listSessions(options: ListOptions = {}): Promise<ListResult<SessionInfo>> {
|
||||
const data = await requestJson<{ items: SessionInfo[]; nextCursor?: string }>(
|
||||
this._client,
|
||||
{
|
||||
method: "GET",
|
||||
path: `/v2/agents/${encodeURIComponent(this.id)}/sessions`,
|
||||
query: { limit: options.limit, cursor: options.cursor },
|
||||
}
|
||||
);
|
||||
return { items: data.items ?? [], nextCursor: data.nextCursor };
|
||||
}
|
||||
|
||||
/** Patch the agent definition. */
|
||||
async update(patch: Partial<AgentCreateOptions>): Promise<void> {
|
||||
await requestJson<void>(this._client, {
|
||||
method: "PATCH",
|
||||
path: `/v2/agents/${encodeURIComponent(this.id)}`,
|
||||
body: stripClientOptions(patch),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete the agent definition. Cascades to its sessions. */
|
||||
async delete(): Promise<void> {
|
||||
await requestJson<void>(this._client, {
|
||||
method: "DELETE",
|
||||
path: `/v2/agents/${encodeURIComponent(this.id)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
/** Create a new agent definition. */
|
||||
static async create(options: AgentCreateOptions): Promise<AgentHandle> {
|
||||
const client = resolveClient(options);
|
||||
const info = await requestJson<AgentInfo>(client, {
|
||||
method: "POST",
|
||||
path: "/v2/agents",
|
||||
body: stripClientOptions(options),
|
||||
});
|
||||
return new AgentHandle(info, client);
|
||||
}
|
||||
|
||||
/** Fetch an existing agent by ID. */
|
||||
static async get(agentId: string, options: ClientOptions = {}): Promise<AgentHandle> {
|
||||
const client = resolveClient(options);
|
||||
const info = await requestJson<AgentInfo>(client, {
|
||||
method: "GET",
|
||||
path: `/v2/agents/${encodeURIComponent(agentId)}`,
|
||||
});
|
||||
return new AgentHandle(info, client);
|
||||
}
|
||||
|
||||
/** List agents accessible to this API key. */
|
||||
static async list(
|
||||
options: ClientOptions & ListOptions = {}
|
||||
): Promise<ListResult<AgentInfo>> {
|
||||
const client = resolveClient(options);
|
||||
const data = await requestJson<{ items: AgentInfo[]; nextCursor?: string }>(
|
||||
client,
|
||||
{
|
||||
method: "GET",
|
||||
path: "/v2/agents",
|
||||
query: { limit: options.limit, cursor: options.cursor },
|
||||
}
|
||||
);
|
||||
return { items: data.items ?? [], nextCursor: data.nextCursor };
|
||||
}
|
||||
}
|
||||
|
||||
function stripClientOptions<T extends Partial<AgentCreateOptions>>(
|
||||
obj: T
|
||||
): Omit<T, "apiKey" | "baseUrl" | "fetch" | "timeoutMs" | "maxRetries"> {
|
||||
const { apiKey, baseUrl, fetch, timeoutMs, maxRetries, ...rest } = obj as AgentCreateOptions;
|
||||
void apiKey;
|
||||
void baseUrl;
|
||||
void fetch;
|
||||
void timeoutMs;
|
||||
void maxRetries;
|
||||
return rest as Omit<T, "apiKey" | "baseUrl" | "fetch" | "timeoutMs" | "maxRetries">;
|
||||
}
|
||||
262
sdks/typescript-agent-sdk/src/client/http.ts
Normal file
262
sdks/typescript-agent-sdk/src/client/http.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* Thin fetch wrapper for the LiteLLM proxy.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Resolve apiKey + baseUrl from explicit args or env
|
||||
* - Inject `Authorization: Bearer <apiKey>`
|
||||
* - Retry retryable errors (5xx, 429) with exponential backoff
|
||||
* - Normalize errors to `LiteLLMAgentError`
|
||||
* - Translate between the SDK's camelCase public API and the backend's
|
||||
* snake_case wire format. Request bodies are converted with
|
||||
* `camelToSnake` before serialization; response JSON is converted with
|
||||
* `snakeToCamel` before being returned to callers. Only object keys are
|
||||
* rewritten — values pass through unchanged.
|
||||
*/
|
||||
|
||||
import { ClientOptions, LiteLLMAgentError } from "../types.js";
|
||||
|
||||
/**
|
||||
* Recursively convert object keys from snake_case to camelCase.
|
||||
* Walks plain objects and arrays; leaves Date/Buffer/etc. untouched.
|
||||
* Single-word keys (no underscores) pass through unchanged.
|
||||
*/
|
||||
export function snakeToCamel(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(snakeToCamel);
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[snakeToCamelKey(k)] = snakeToCamel(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively convert object keys from camelCase to snake_case.
|
||||
* Walks plain objects and arrays; leaves Date/Buffer/etc. untouched.
|
||||
* Single-word keys (no uppercase) pass through unchanged.
|
||||
*/
|
||||
export function camelToSnake(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(camelToSnake);
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[camelToSnakeKey(k)] = camelToSnake(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function snakeToCamelKey(key: string): string {
|
||||
if (!key.includes("_")) return key;
|
||||
return key.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function camelToSnakeKey(key: string): string {
|
||||
// Only rewrite if there's at least one uppercase letter to convert.
|
||||
if (!/[A-Z]/.test(key)) return key;
|
||||
return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
if (v === null || typeof v !== "object") return false;
|
||||
const proto = Object.getPrototypeOf(v);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.litellm.ai";
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
/** Upper bound for honoring `Retry-After` headers, to defend against a
|
||||
* misbehaving or adversarial server returning extreme values. */
|
||||
const MAX_RETRY_AFTER_MS = 60_000;
|
||||
|
||||
export interface ResolvedClient {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
fetch: typeof fetch;
|
||||
timeoutMs: number;
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
export function resolveClient(options: ClientOptions = {}): ResolvedClient {
|
||||
const apiKey =
|
||||
options.apiKey ??
|
||||
(typeof process !== "undefined" ? process.env?.LITELLM_API_KEY : undefined);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new LiteLLMAgentError(
|
||||
"Missing apiKey. Pass `apiKey` explicitly or set LITELLM_API_KEY in the environment.",
|
||||
{ code: "missing_api_key" },
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RequestOpts {
|
||||
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | undefined>;
|
||||
headers?: Record<string, string>;
|
||||
/** If true, the response is returned raw (used for SSE). */
|
||||
stream?: boolean;
|
||||
/** Abort signal forwarded to fetch. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function request(
|
||||
client: ResolvedClient,
|
||||
opts: RequestOpts,
|
||||
): Promise<Response> {
|
||||
const url = buildUrl(client.baseUrl, opts.path, opts.query);
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${client.apiKey}`,
|
||||
Accept: opts.stream ? "text/event-stream" : "application/json",
|
||||
...(opts.headers ?? {}),
|
||||
};
|
||||
if (opts.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method: opts.method ?? "GET",
|
||||
headers,
|
||||
body:
|
||||
opts.body !== undefined
|
||||
? JSON.stringify(camelToSnake(opts.body))
|
||||
: undefined,
|
||||
signal: opts.signal,
|
||||
};
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= client.maxRetries; attempt++) {
|
||||
try {
|
||||
const res = await withTimeout(client.fetch(url, init), client.timeoutMs);
|
||||
if (!res.ok) {
|
||||
const err = await toAgentError(res);
|
||||
if (err.retryable && attempt < client.maxRetries) {
|
||||
await sleep(backoffMs(attempt, res));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return res;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
if (e instanceof LiteLLMAgentError) {
|
||||
if (!e.retryable || attempt === client.maxRetries) throw e;
|
||||
await sleep(backoffMs(attempt));
|
||||
continue;
|
||||
}
|
||||
// Network errors are retryable.
|
||||
if (attempt === client.maxRetries) throw e;
|
||||
await sleep(backoffMs(attempt));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function requestJson<T>(
|
||||
client: ResolvedClient,
|
||||
opts: RequestOpts,
|
||||
): Promise<T> {
|
||||
const res = await request(client, opts);
|
||||
if (res.status === 204) return undefined as unknown as T;
|
||||
const parsed = await res.json();
|
||||
return snakeToCamel(parsed) as T;
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
query?: Record<string, string | number | undefined>,
|
||||
): string {
|
||||
const url = new URL(path.startsWith("/") ? path : `/${path}`, baseUrl + "/");
|
||||
if (query) {
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v === undefined) continue;
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function toAgentError(res: Response): Promise<LiteLLMAgentError> {
|
||||
const status = res.status;
|
||||
let code = `http_${status}`;
|
||||
let message = `HTTP ${status}`;
|
||||
try {
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("application/json")) {
|
||||
const body = (await res.json()) as {
|
||||
error?: { code?: string; message?: string };
|
||||
detail?: unknown;
|
||||
};
|
||||
if (body?.error?.code) code = body.error.code;
|
||||
if (body?.error?.message) message = body.error.message;
|
||||
else if (typeof body?.detail === "string") message = body.detail;
|
||||
} else {
|
||||
const text = await res.text();
|
||||
if (text) message = text.slice(0, 500);
|
||||
}
|
||||
} catch {
|
||||
// ignore body-parse failures
|
||||
}
|
||||
const retryable = status >= 500 || status === 429;
|
||||
return new LiteLLMAgentError(message, { code, status, retryable });
|
||||
}
|
||||
|
||||
function backoffMs(attempt: number, res?: Response): number {
|
||||
if (res) {
|
||||
const ra = res.headers.get("retry-after");
|
||||
if (ra) {
|
||||
const n = Number(ra);
|
||||
if (!Number.isNaN(n)) {
|
||||
return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, n * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 250ms, 500ms, 1s, 2s, ...
|
||||
return 250 * Math.pow(2, attempt);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
||||
if (!ms || ms <= 0) return p;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new LiteLLMAgentError(`Request timed out after ${ms}ms`, {
|
||||
code: "timeout",
|
||||
retryable: true,
|
||||
}),
|
||||
),
|
||||
ms,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return (await Promise.race([p, timeout])) as T;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
186
sdks/typescript-agent-sdk/src/client/sse.ts
Normal file
186
sdks/typescript-agent-sdk/src/client/sse.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* SSE client with auto-reconnect via `Last-Event-ID` / `?starting_seq=N`.
|
||||
*
|
||||
* Yields parsed `RunEvent` objects. When the stream drops, it transparently
|
||||
* reconnects from the last seq it saw. Honors AbortSignal.
|
||||
*/
|
||||
|
||||
import { createParser, type EventSourceMessage } from "eventsource-parser";
|
||||
import { LiteLLMAgentError, type RunEvent } from "../types.js";
|
||||
import { request, type ResolvedClient } from "./http.js";
|
||||
|
||||
export interface StreamRunOptions {
|
||||
startingSeq?: number;
|
||||
signal?: AbortSignal;
|
||||
/** Max reconnect attempts on dropped sockets. Default: 5. */
|
||||
maxReconnects?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream events from `GET /v2/sessions/{sid}/runs/{rid}/events`.
|
||||
*
|
||||
* Resume contract:
|
||||
* - On every event, we record `seq`.
|
||||
* - On reconnect, we send both `Last-Event-ID: <seq>` (per the SSE spec)
|
||||
* and `?starting_seq=<seq+1>` (LiteLLM-specific). The proxy honors
|
||||
* whichever it understands and replays from there.
|
||||
*/
|
||||
export async function* streamRunEvents(
|
||||
client: ResolvedClient,
|
||||
sessionId: string,
|
||||
runId: string,
|
||||
opts: StreamRunOptions = {},
|
||||
): AsyncIterable<RunEvent> {
|
||||
const maxReconnects = opts.maxReconnects ?? 5;
|
||||
let lastSeq = opts.startingSeq !== undefined ? opts.startingSeq - 1 : -1;
|
||||
let reconnects = 0;
|
||||
|
||||
while (true) {
|
||||
const startingSeq = lastSeq >= 0 ? lastSeq + 1 : undefined;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await request(client, {
|
||||
method: "GET",
|
||||
path: `/v2/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}/events`,
|
||||
query:
|
||||
startingSeq !== undefined ? { starting_seq: startingSeq } : undefined,
|
||||
headers:
|
||||
startingSeq !== undefined ? { "Last-Event-ID": String(lastSeq) } : {},
|
||||
stream: true,
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
if (opts.signal?.aborted) return;
|
||||
if (reconnects >= maxReconnects) throw e;
|
||||
reconnects++;
|
||||
await sleep(backoffMs(reconnects));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new LiteLLMAgentError("SSE response had no body", {
|
||||
code: "stream_no_body",
|
||||
});
|
||||
}
|
||||
|
||||
let droppedMidStream = false;
|
||||
let sawProgressOnThisConnection = false;
|
||||
try {
|
||||
for await (const event of parseSSE(res.body, opts.signal)) {
|
||||
const parsed = decodeEvent(event);
|
||||
if (parsed === null) continue;
|
||||
if (parsed.seq <= lastSeq) {
|
||||
// Replayed event we already saw; drop it.
|
||||
continue;
|
||||
}
|
||||
lastSeq = parsed.seq;
|
||||
sawProgressOnThisConnection = true;
|
||||
yield parsed;
|
||||
if (parsed.type === "done" || parsed.type === "error") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Stream ended cleanly without a `done` event — server hung up.
|
||||
droppedMidStream = true;
|
||||
} catch (e) {
|
||||
if (opts.signal?.aborted) return;
|
||||
droppedMidStream = true;
|
||||
}
|
||||
|
||||
if (!droppedMidStream) return;
|
||||
// Reset the reconnect budget if this connection delivered new events.
|
||||
// The counter tracks *consecutive* failures, not lifetime drops, so a
|
||||
// long-running stream with sporadic transient drops is not penalized.
|
||||
if (sawProgressOnThisConnection) {
|
||||
reconnects = 0;
|
||||
}
|
||||
if (reconnects >= maxReconnects) {
|
||||
throw new LiteLLMAgentError(
|
||||
`SSE reconnect budget exhausted (${maxReconnects})`,
|
||||
{ code: "sse_reconnect_exhausted" },
|
||||
);
|
||||
}
|
||||
reconnects++;
|
||||
await sleep(backoffMs(reconnects));
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEvent(msg: EventSourceMessage): RunEvent | null {
|
||||
if (!msg.data) return null;
|
||||
try {
|
||||
const obj = JSON.parse(msg.data) as Partial<RunEvent>;
|
||||
if (typeof obj.seq !== "number" || typeof obj.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
seq: obj.seq,
|
||||
type: obj.type,
|
||||
data: obj.data ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function* parseSSE(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<EventSourceMessage> {
|
||||
const queue: EventSourceMessage[] = [];
|
||||
const state: { resolve: (() => void) | null; done: boolean } = {
|
||||
resolve: null,
|
||||
done: false,
|
||||
};
|
||||
|
||||
const parser = createParser({
|
||||
onEvent: (e) => {
|
||||
queue.push(e);
|
||||
state.resolve?.();
|
||||
},
|
||||
});
|
||||
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const pump = (async () => {
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel();
|
||||
return;
|
||||
}
|
||||
const { value, done: rdone } = await reader.read();
|
||||
if (rdone) return;
|
||||
parser.feed(decoder.decode(value, { stream: true }));
|
||||
state.resolve?.();
|
||||
}
|
||||
} finally {
|
||||
state.done = true;
|
||||
state.resolve?.();
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
while (queue.length > 0) {
|
||||
const ev = queue.shift()!;
|
||||
yield ev;
|
||||
}
|
||||
if (state.done) return;
|
||||
await new Promise<void>((r) => {
|
||||
state.resolve = r;
|
||||
});
|
||||
state.resolve = null;
|
||||
}
|
||||
} finally {
|
||||
await pump.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function backoffMs(attempt: number): number {
|
||||
return Math.min(5_000, 250 * Math.pow(2, attempt));
|
||||
}
|
||||
35
sdks/typescript-agent-sdk/src/index.ts
Normal file
35
sdks/typescript-agent-sdk/src/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* @litellm/agent-sdk — public surface.
|
||||
*
|
||||
* Three-level hierarchy:
|
||||
* Agent — definition (model, system prompt, tools)
|
||||
* ↓
|
||||
* Session — VM sandbox running under an agent
|
||||
* ↓
|
||||
* Run — single execution within a session
|
||||
*/
|
||||
|
||||
export { Agent, AgentHandle } from "./agent.js";
|
||||
export { SessionHandle } from "./session.js";
|
||||
export { Run } from "./run.js";
|
||||
|
||||
export type {
|
||||
AgentCreateOptions,
|
||||
AgentInfo,
|
||||
ClientOptions,
|
||||
ConversationTurn,
|
||||
CreateSessionOptions,
|
||||
ListOptions,
|
||||
ListResult,
|
||||
Message,
|
||||
ModelRef,
|
||||
RunEvent,
|
||||
RunInfo,
|
||||
RunResult,
|
||||
RunStatus,
|
||||
SDKImage,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
} from "./types.js";
|
||||
|
||||
export { LiteLLMAgentError } from "./types.js";
|
||||
135
sdks/typescript-agent-sdk/src/run.ts
Normal file
135
sdks/typescript-agent-sdk/src/run.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Run — a single execution of an agent inside a session.
|
||||
*
|
||||
* Lifecycle: queued → running → completed | failed | cancelled.
|
||||
*/
|
||||
|
||||
import { requestJson, type ResolvedClient } from "./client/http.js";
|
||||
import { streamRunEvents } from "./client/sse.js";
|
||||
import {
|
||||
LiteLLMAgentError,
|
||||
type RunEvent,
|
||||
type RunInfo,
|
||||
type RunResult,
|
||||
type RunStatus,
|
||||
} from "./types.js";
|
||||
|
||||
const TERMINAL_STATES: RunStatus[] = ["finished", "cancelled", "error"];
|
||||
const DEFAULT_WAIT_POLL_MS = 500;
|
||||
|
||||
export class Run {
|
||||
readonly id: string;
|
||||
readonly sessionId: string;
|
||||
|
||||
private _status: RunStatus;
|
||||
private _result: string | null;
|
||||
private _git: RunInfo["git"];
|
||||
private readonly _client: ResolvedClient;
|
||||
|
||||
constructor(info: RunInfo, client: ResolvedClient) {
|
||||
this.id = info.id;
|
||||
this.sessionId = info.sessionId;
|
||||
this._status = info.status;
|
||||
this._result = info.result;
|
||||
this._git = info.git;
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
get status(): RunStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get result(): string | null {
|
||||
return this._result;
|
||||
}
|
||||
|
||||
get git(): RunInfo["git"] {
|
||||
return this._git;
|
||||
}
|
||||
|
||||
/** Open an SSE stream of events for this run. */
|
||||
stream(
|
||||
opts: { startingSeq?: number; signal?: AbortSignal } = {},
|
||||
): AsyncIterable<RunEvent> {
|
||||
return streamRunEvents(this._client, this.sessionId, this.id, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until the run reaches a terminal status.
|
||||
*
|
||||
* @param opts.signal Optional `AbortSignal` to bail out early. Throws
|
||||
* `LiteLLMAgentError({ code: "wait_aborted" })`.
|
||||
* @param opts.timeoutMs Optional ceiling on total wait time. Throws
|
||||
* `LiteLLMAgentError({ code: "wait_timeout" })`.
|
||||
* @param opts.pollMs Override the poll interval (default 500ms).
|
||||
*/
|
||||
async wait(
|
||||
opts: { signal?: AbortSignal; timeoutMs?: number; pollMs?: number } = {},
|
||||
): Promise<RunResult> {
|
||||
const pollMs = opts.pollMs ?? DEFAULT_WAIT_POLL_MS;
|
||||
const deadline =
|
||||
opts.timeoutMs !== undefined && opts.timeoutMs > 0
|
||||
? Date.now() + opts.timeoutMs
|
||||
: undefined;
|
||||
|
||||
while (!TERMINAL_STATES.includes(this._status)) {
|
||||
if (opts.signal?.aborted) {
|
||||
throw new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" });
|
||||
}
|
||||
if (deadline !== undefined && Date.now() >= deadline) {
|
||||
throw new LiteLLMAgentError(
|
||||
`wait() timed out after ${opts.timeoutMs}ms`,
|
||||
{ code: "wait_timeout" },
|
||||
);
|
||||
}
|
||||
const info = await requestJson<RunInfo>(this._client, {
|
||||
method: "GET",
|
||||
path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}`,
|
||||
signal: opts.signal,
|
||||
});
|
||||
this._status = info.status;
|
||||
this._result = info.result;
|
||||
this._git = info.git;
|
||||
if (TERMINAL_STATES.includes(this._status)) break;
|
||||
await sleep(pollMs, opts.signal);
|
||||
}
|
||||
return {
|
||||
id: this.id,
|
||||
status: this._status,
|
||||
result: this._result,
|
||||
git: this._git,
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: There is no per-run conversation endpoint on the backend. The
|
||||
// conversation is session-scoped — call `session.conversation()` (on the
|
||||
// owning `SessionHandle`) for the full turn history.
|
||||
|
||||
/** Cancel a running run; no-op if already terminal. */
|
||||
async cancel(): Promise<void> {
|
||||
if (TERMINAL_STATES.includes(this._status)) return;
|
||||
await requestJson<void>(this._client, {
|
||||
method: "POST",
|
||||
path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/cancel`,
|
||||
});
|
||||
this._status = "cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" }));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" }));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
137
sdks/typescript-agent-sdk/src/session.ts
Normal file
137
sdks/typescript-agent-sdk/src/session.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* SessionHandle — a single VM session running under one agent.
|
||||
*
|
||||
* Each session is a long-lived sandbox (Phase 1: noop VM) that processes
|
||||
* one or more `Run`s sequentially. `send()` starts a new run; `followup()`
|
||||
* queues a message into the active run.
|
||||
*/
|
||||
|
||||
import { requestJson, type ResolvedClient } from "./client/http.js";
|
||||
import { Run } from "./run.js";
|
||||
import {
|
||||
type ConversationTurn,
|
||||
type ListOptions,
|
||||
type ListResult,
|
||||
type RunInfo,
|
||||
type SDKImage,
|
||||
type SessionInfo,
|
||||
type SessionStatus,
|
||||
} from "./types.js";
|
||||
|
||||
interface SendInput {
|
||||
text: string;
|
||||
images?: SDKImage[];
|
||||
}
|
||||
|
||||
export class SessionHandle {
|
||||
readonly id: string;
|
||||
readonly agentId: string;
|
||||
|
||||
private _status: SessionStatus;
|
||||
private readonly _client: ResolvedClient;
|
||||
|
||||
constructor(info: SessionInfo, client: ResolvedClient) {
|
||||
this.id = info.id;
|
||||
this.agentId = info.agentId;
|
||||
this._status = info.status;
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
get status(): SessionStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/** Start a new run with `input`. Throws 409 if a run is already in flight. */
|
||||
async send(input: string | SendInput): Promise<Run> {
|
||||
const body = normalizeSendInput(input);
|
||||
const info = await requestJson<RunInfo>(this._client, {
|
||||
method: "POST",
|
||||
path: `/v2/sessions/${encodeURIComponent(this.id)}/runs`,
|
||||
body,
|
||||
});
|
||||
return new Run(info, this._client);
|
||||
}
|
||||
|
||||
/** Queue a follow-up message into the active run. */
|
||||
async followup(message: string): Promise<void> {
|
||||
// Wire shape matches backend `FollowupCreate`: {prompt: {text: "..."}}.
|
||||
await requestJson<void>(this._client, {
|
||||
method: "POST",
|
||||
path: `/v2/sessions/${encodeURIComponent(this.id)}/followup`,
|
||||
body: { prompt: { text: message } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single run by ID. */
|
||||
async getRun(runId: string): Promise<Run> {
|
||||
const info = await requestJson<RunInfo>(this._client, {
|
||||
method: "GET",
|
||||
path: `/v2/sessions/${encodeURIComponent(this.id)}/runs/${encodeURIComponent(runId)}`,
|
||||
});
|
||||
return new Run(info, this._client);
|
||||
}
|
||||
|
||||
/** List runs belonging to this session. */
|
||||
async listRuns(options: ListOptions = {}): Promise<ListResult<Run>> {
|
||||
const data = await requestJson<{ items: RunInfo[]; nextCursor?: string }>(
|
||||
this._client,
|
||||
{
|
||||
method: "GET",
|
||||
path: `/v2/sessions/${encodeURIComponent(this.id)}/runs`,
|
||||
query: { limit: options.limit, cursor: options.cursor },
|
||||
},
|
||||
);
|
||||
return {
|
||||
items: (data.items ?? []).map((info) => new Run(info, this._client)),
|
||||
nextCursor: data.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** Snapshot of the full conversation across runs. */
|
||||
async conversation(): Promise<ConversationTurn[]> {
|
||||
const data = await requestJson<{ turns: ConversationTurn[] }>(
|
||||
this._client,
|
||||
{
|
||||
method: "GET",
|
||||
path: `/v2/sessions/${encodeURIComponent(this.id)}/conversation`,
|
||||
},
|
||||
);
|
||||
return data.turns ?? [];
|
||||
}
|
||||
|
||||
/** Tear down the VM and delete the session. */
|
||||
async delete(): Promise<void> {
|
||||
await requestJson<void>(this._client, {
|
||||
method: "DELETE",
|
||||
path: `/v2/sessions/${encodeURIComponent(this.id)}`,
|
||||
});
|
||||
this._status = "terminated";
|
||||
}
|
||||
|
||||
/** Alias of `delete()`. */
|
||||
async terminate(): Promise<void> {
|
||||
await this.delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables `await using session = await agent.createSession(...)`.
|
||||
* Calls DELETE on scope exit.
|
||||
*/
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
if (this._status !== "terminated") {
|
||||
try {
|
||||
await this.delete();
|
||||
} catch {
|
||||
// Best-effort cleanup — do not throw out of dispose.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSendInput(input: string | SendInput): {
|
||||
text: string;
|
||||
images: SDKImage[];
|
||||
} {
|
||||
if (typeof input === "string") return { text: input, images: [] };
|
||||
return { text: input.text, images: input.images ?? [] };
|
||||
}
|
||||
160
sdks/typescript-agent-sdk/src/types.ts
Normal file
160
sdks/typescript-agent-sdk/src/types.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Public types for @litellm/agent-sdk.
|
||||
*
|
||||
* These mirror the wire shapes posted by Epic A on LIT-2880. When A1
|
||||
* publishes the canonical OpenAPI/wire-shape comment, regenerate the
|
||||
* `Wire*` aliases below; the public surface (Agent/Session/Run) should
|
||||
* remain stable.
|
||||
*/
|
||||
|
||||
/** Configuration shared by every SDK call. */
|
||||
export interface ClientOptions {
|
||||
/** LiteLLM API key. Falls back to `process.env.LITELLM_API_KEY`. */
|
||||
apiKey?: string;
|
||||
/** Base URL of the LiteLLM proxy. Defaults to `https://api.litellm.ai`. */
|
||||
baseUrl?: string;
|
||||
/** Optional fetch override for tests / custom transports. */
|
||||
fetch?: typeof fetch;
|
||||
/** Default request timeout (ms). Default: 60_000. */
|
||||
timeoutMs?: number;
|
||||
/** Max retry attempts on retryable errors. Default: 3. */
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/** Pagination args accepted by `list*` methods. */
|
||||
export interface ListOptions {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface ListResult<T> {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/** Used to identify the model an agent should run on. */
|
||||
export interface ModelRef {
|
||||
/** LiteLLM model ID, e.g. `claude-4.6-sonnet`. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Image attachment passed to `session.send`. */
|
||||
export interface SDKImage {
|
||||
/** Either a data URL or an https URL. */
|
||||
url: string;
|
||||
/** Optional MIME type hint. */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/** Args accepted by `Agent.create`. */
|
||||
export interface AgentCreateOptions extends ClientOptions {
|
||||
name: string;
|
||||
model: ModelRef;
|
||||
systemPrompt?: string;
|
||||
/** Free-form tags / metadata persisted on the agent definition. */
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Args accepted by `agent.createSession`. */
|
||||
export interface CreateSessionOptions {
|
||||
/** Repos to clone into the session VM. */
|
||||
repos?: { url: string; startingRef?: string }[];
|
||||
/** Environment variables surfaced to the VM. */
|
||||
envVars?: Record<string, string>;
|
||||
/** Free-form tags / metadata persisted on the session. */
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type SessionStatus =
|
||||
| "provisioning"
|
||||
| "ready"
|
||||
| "busy"
|
||||
| "error"
|
||||
| "terminated";
|
||||
|
||||
export type RunStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "finished"
|
||||
| "cancelled"
|
||||
| "error";
|
||||
|
||||
/** Lightweight info returned by list endpoints. */
|
||||
export interface AgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
model: ModelRef;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
agentId: string;
|
||||
status: SessionStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RunInfo {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
status: RunStatus;
|
||||
result: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
git?: { branches: { branch: string; prUrl: string | null }[] };
|
||||
}
|
||||
|
||||
export interface ConversationTurn {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string;
|
||||
runId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Message extends ConversationTurn {}
|
||||
|
||||
/** Streamed event off `run.stream()`. */
|
||||
export interface RunEvent {
|
||||
/** Monotonic per-run sequence; required for resume. */
|
||||
seq: number;
|
||||
type:
|
||||
| "delta"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "status"
|
||||
| "done"
|
||||
| "error"
|
||||
| string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/** Resolved value from `run.wait()`. */
|
||||
export interface RunResult {
|
||||
id: string;
|
||||
status: RunStatus;
|
||||
result: string | null;
|
||||
git?: { branches: { branch: string; prUrl: string | null }[] };
|
||||
}
|
||||
|
||||
/** Error thrown by the SDK. Mirrors Cursor SDK's `CursorAgentError` shape. */
|
||||
export class LiteLLMAgentError extends Error {
|
||||
/** Stable machine-readable code, e.g. `not_found`, `rate_limited`. */
|
||||
code: string;
|
||||
/** HTTP status, when applicable. */
|
||||
status?: number;
|
||||
/** Whether the call is safe to retry. */
|
||||
retryable: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: { code: string; status?: number; retryable?: boolean } = {
|
||||
code: "unknown",
|
||||
},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LiteLLMAgentError";
|
||||
this.code = options.code;
|
||||
this.status = options.status;
|
||||
this.retryable = options.retryable ?? false;
|
||||
}
|
||||
}
|
||||
59
sdks/typescript-agent-sdk/tests/agent-reuse.test.ts
Normal file
59
sdks/typescript-agent-sdk/tests/agent-reuse.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Validation #3 — Agent reuse across sessions.
|
||||
*
|
||||
* Creates ONE agent, then 3 sessions under it. Asserts: same agentId,
|
||||
* distinct sessionIds, distinct VMs.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Agent } from "../src/index.js";
|
||||
import { MockProxy } from "./mock-proxy.js";
|
||||
|
||||
describe("agent reuse across sessions", () => {
|
||||
let proxy: MockProxy;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
proxy = new MockProxy();
|
||||
baseUrl = await proxy.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
it("creates 3 sessions under one agent with distinct IDs and distinct VMs", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "shared",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
|
||||
const sessions = await Promise.all([
|
||||
agent.createSession(),
|
||||
agent.createSession(),
|
||||
agent.createSession(),
|
||||
]);
|
||||
|
||||
// All sessions share the agent ID.
|
||||
for (const s of sessions) {
|
||||
expect(s.agentId).toBe(agent.id);
|
||||
}
|
||||
|
||||
// Session IDs are unique.
|
||||
const ids = sessions.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
|
||||
// Mock proxy assigns each session a distinct VM ID.
|
||||
const proxyAgent = proxy.agents.get(agent.id);
|
||||
expect(proxyAgent).toBeDefined();
|
||||
const vmIds = [...proxyAgent!.sessions.values()].map((s) => s.vmId);
|
||||
expect(new Set(vmIds).size).toBe(3);
|
||||
|
||||
// listSessions reflects all 3.
|
||||
const listed = await agent.listSessions();
|
||||
expect(listed.items.length).toBe(3);
|
||||
expect(new Set(listed.items.map((i) => i.id))).toEqual(new Set(ids));
|
||||
});
|
||||
});
|
||||
123
sdks/typescript-agent-sdk/tests/agent.test.ts
Normal file
123
sdks/typescript-agent-sdk/tests/agent.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Spec tests for `Agent` static API.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Agent, LiteLLMAgentError } from "../src/index.js";
|
||||
import { MockProxy } from "./mock-proxy.js";
|
||||
|
||||
describe("Agent", () => {
|
||||
let proxy: MockProxy;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
proxy = new MockProxy();
|
||||
baseUrl = await proxy.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
it("Agent.create persists name + model and returns an AgentHandle", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "alpha",
|
||||
model: { id: "claude-4.6-sonnet" },
|
||||
systemPrompt: "be helpful",
|
||||
metadata: { team: "infra" },
|
||||
});
|
||||
expect(agent.id).toMatch(/^agt_/);
|
||||
expect(agent.name).toBe("alpha");
|
||||
|
||||
const proxyAgent = proxy.agents.get(agent.id);
|
||||
expect(proxyAgent?.model.id).toBe("claude-4.6-sonnet");
|
||||
expect(proxyAgent?.systemPrompt).toBe("be helpful");
|
||||
expect(proxyAgent?.metadata.team).toBe("infra");
|
||||
});
|
||||
|
||||
it("Agent.get round-trips a previously-created agent", async () => {
|
||||
const created = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "fetch-me",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
const fetched = await Agent.get(created.id, { apiKey: "test-key", baseUrl });
|
||||
expect(fetched.id).toBe(created.id);
|
||||
expect(fetched.name).toBe("fetch-me");
|
||||
});
|
||||
|
||||
it("Agent.delete removes the agent from the server", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "transient",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
expect(proxy.agents.has(agent.id)).toBe(true);
|
||||
await agent.delete();
|
||||
expect(proxy.agents.has(agent.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects with a 401 LiteLLMAgentError when apiKey is wrong", async () => {
|
||||
await expect(
|
||||
Agent.create({
|
||||
apiKey: "wrong-key",
|
||||
baseUrl,
|
||||
name: "x",
|
||||
model: { id: "test-model" },
|
||||
// no retries on 401
|
||||
maxRetries: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "LiteLLMAgentError",
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws missing_api_key when no apiKey provided and env is unset", async () => {
|
||||
const previous = process.env.LITELLM_API_KEY;
|
||||
delete process.env.LITELLM_API_KEY;
|
||||
try {
|
||||
await expect(
|
||||
// @ts-expect-error — testing runtime guard with missing apiKey
|
||||
Agent.create({ baseUrl, name: "x", model: { id: "m" } })
|
||||
).rejects.toMatchObject({
|
||||
name: "LiteLLMAgentError",
|
||||
code: "missing_api_key",
|
||||
});
|
||||
} finally {
|
||||
if (previous !== undefined) process.env.LITELLM_API_KEY = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("retries on a transient 503 with retry-after and ultimately succeeds", async () => {
|
||||
const proxy503 = new MockProxy({ failFirstRequest: true });
|
||||
const url503 = await proxy503.start();
|
||||
try {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl: url503,
|
||||
name: "resilient",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
expect(agent.id).toMatch(/^agt_/);
|
||||
} finally {
|
||||
await proxy503.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("LiteLLMAgentError carries code, status, and retryable flag", () => {
|
||||
const e = new LiteLLMAgentError("nope", {
|
||||
code: "rate_limited",
|
||||
status: 429,
|
||||
retryable: true,
|
||||
});
|
||||
expect(e.code).toBe("rate_limited");
|
||||
expect(e.status).toBe(429);
|
||||
expect(e.retryable).toBe(true);
|
||||
expect(e.name).toBe("LiteLLMAgentError");
|
||||
});
|
||||
});
|
||||
63
sdks/typescript-agent-sdk/tests/dispose.test.ts
Normal file
63
sdks/typescript-agent-sdk/tests/dispose.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Validation #6 — Async dispose tears down VM.
|
||||
*
|
||||
* Uses `await using session = ...`; checks proxy state shows session
|
||||
* terminated after scope exit.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Agent, type SessionHandle } from "../src/index.js";
|
||||
import { MockProxy } from "./mock-proxy.js";
|
||||
|
||||
describe("Symbol.asyncDispose", () => {
|
||||
let proxy: MockProxy;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
proxy = new MockProxy();
|
||||
baseUrl = await proxy.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
it("calls DELETE /v2/sessions/{id} when the await-using scope exits", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "disposer",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
|
||||
let sessionId: string;
|
||||
{
|
||||
// `await using` requires TS 5.2+ and proper polyfill. We invoke the
|
||||
// dispose method explicitly so the test is portable across runners
|
||||
// without forcing the explicit-resource-management transform.
|
||||
const session: SessionHandle = await agent.createSession();
|
||||
sessionId = session.id;
|
||||
const proxySession = proxy.findSession(sessionId);
|
||||
expect(proxySession?.status).not.toBe("terminated");
|
||||
await session[Symbol.asyncDispose]();
|
||||
}
|
||||
|
||||
const after = proxy.findSession(sessionId);
|
||||
expect(after?.status).toBe("terminated");
|
||||
});
|
||||
|
||||
it("dispose is a no-op once the session has already been deleted", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "double-dispose",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
|
||||
const session = await agent.createSession();
|
||||
await session.delete();
|
||||
|
||||
// A second dispose must not throw.
|
||||
await expect(session[Symbol.asyncDispose]()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
57
sdks/typescript-agent-sdk/tests/followup.test.ts
Normal file
57
sdks/typescript-agent-sdk/tests/followup.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Validation #5 — session.followup() queues correctly.
|
||||
*
|
||||
* Sends a long prompt via send(); while it's running, calls followup().
|
||||
* Expect: no 409, follow-up message appears in conversation after current
|
||||
* run terminal.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Agent } from "../src/index.js";
|
||||
import { MockProxy } from "./mock-proxy.js";
|
||||
|
||||
describe("session.followup()", () => {
|
||||
let proxy: MockProxy;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
proxy = new MockProxy();
|
||||
baseUrl = await proxy.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
it("accepts a follow-up message while a run is in flight without a 409", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "follower",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send("the long initial prompt");
|
||||
|
||||
// Simulate run-in-progress by leaving status non-terminal until we say so.
|
||||
// followup() should NOT throw a 409.
|
||||
await expect(
|
||||
session.followup("please also handle the empty-input case")
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// Drive the run to completion.
|
||||
proxy.emit(run.id, "delta", { text: "ok" });
|
||||
proxy.complete(run.id, "done");
|
||||
await run.wait();
|
||||
|
||||
const turns = await session.conversation();
|
||||
const userTurns = turns.filter((t) => t.role === "user").map((t) => t.content);
|
||||
expect(userTurns).toContain("the long initial prompt");
|
||||
expect(userTurns).toContain("please also handle the empty-input case");
|
||||
|
||||
// Follow-up should be ordered after the initial prompt.
|
||||
const initialIdx = userTurns.indexOf("the long initial prompt");
|
||||
const followupIdx = userTurns.indexOf("please also handle the empty-input case");
|
||||
expect(followupIdx).toBeGreaterThan(initialIdx);
|
||||
});
|
||||
});
|
||||
523
sdks/typescript-agent-sdk/tests/mock-proxy.ts
Normal file
523
sdks/typescript-agent-sdk/tests/mock-proxy.ts
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
/**
|
||||
* In-memory mock proxy for SDK tests.
|
||||
*
|
||||
* We don't use msw here because we need to test SSE streaming + mid-stream
|
||||
* socket drops, which fetch interception libraries handle awkwardly.
|
||||
* Instead we spin up a real `http.Server` on a random port and point the SDK
|
||||
* at it.
|
||||
*/
|
||||
|
||||
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { AddressInfo } from "node:net";
|
||||
|
||||
interface AgentRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
model: { id: string };
|
||||
systemPrompt?: string;
|
||||
metadata: Record<string, string>;
|
||||
createdAt: string;
|
||||
sessions: Map<string, SessionRecord>;
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
id: string;
|
||||
agentId: string;
|
||||
status: "provisioning" | "ready" | "busy" | "error" | "terminated";
|
||||
vmId: string;
|
||||
createdAt: string;
|
||||
runs: Map<string, RunRecord>;
|
||||
followups: string[];
|
||||
conversation: {
|
||||
role: string;
|
||||
content: string;
|
||||
runId?: string;
|
||||
createdAt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface RunRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
status: "queued" | "running" | "finished" | "cancelled" | "error";
|
||||
result: string | null;
|
||||
events: { seq: number; type: string; data: unknown }[];
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
/** active SSE connections — set when /events is connected */
|
||||
sseClients: Set<ServerResponse>;
|
||||
}
|
||||
|
||||
export interface MockProxyOptions {
|
||||
/**
|
||||
* If set, the first SSE connection for a run is killed after this many ms,
|
||||
* to simulate a mid-stream socket drop. Reset to `false` after the first
|
||||
* drop to let reconnection succeed.
|
||||
*/
|
||||
killSseAfterMs?: number;
|
||||
/** If true, emit a 503 with retry-after on the first request. */
|
||||
failFirstRequest?: boolean;
|
||||
}
|
||||
|
||||
export class MockProxy {
|
||||
readonly server: http.Server;
|
||||
readonly agents: Map<string, AgentRecord> = new Map();
|
||||
private firstRequestServed = false;
|
||||
private firstStreamDropped = false;
|
||||
private idCounter = 0;
|
||||
options: MockProxyOptions;
|
||||
|
||||
constructor(options: MockProxyOptions = {}) {
|
||||
this.options = options;
|
||||
this.server = http.createServer((req, res) => this.handle(req, res));
|
||||
}
|
||||
|
||||
async start(): Promise<string> {
|
||||
await new Promise<void>((resolve) =>
|
||||
this.server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
const addr = this.server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${addr.port}`;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// Force-close any open SSE clients first.
|
||||
for (const agent of this.agents.values()) {
|
||||
for (const session of agent.sessions.values()) {
|
||||
for (const run of session.runs.values()) {
|
||||
for (const c of run.sseClients) c.end();
|
||||
run.sseClients.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
this.server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
}
|
||||
|
||||
/** Lookup helpers exposed for tests. */
|
||||
findRun(runId: string): RunRecord | undefined {
|
||||
for (const agent of this.agents.values()) {
|
||||
for (const session of agent.sessions.values()) {
|
||||
const run = session.runs.get(runId);
|
||||
if (run) return run;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
findSession(sessionId: string): SessionRecord | undefined {
|
||||
for (const agent of this.agents.values()) {
|
||||
const s = agent.sessions.get(sessionId);
|
||||
if (s) return s;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Append a new event to a run, fan it out to live SSE clients. */
|
||||
emit(runId: string, type: string, data: unknown): void {
|
||||
const run = this.findRun(runId);
|
||||
if (!run) return;
|
||||
const seq = run.events.length;
|
||||
const event = { seq, type, data };
|
||||
run.events.push(event);
|
||||
const payload = `id: ${seq}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
for (const client of run.sseClients) {
|
||||
client.write(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark a run completed and emit a `done` event. */
|
||||
complete(runId: string, result: string): void {
|
||||
const run = this.findRun(runId);
|
||||
if (!run) return;
|
||||
run.status = "finished";
|
||||
run.result = result;
|
||||
run.completedAt = new Date().toISOString();
|
||||
this.emit(runId, "done", { result });
|
||||
for (const client of run.sseClients) client.end();
|
||||
run.sseClients.clear();
|
||||
}
|
||||
|
||||
private nextId(prefix: string): string {
|
||||
this.idCounter++;
|
||||
return `${prefix}_${this.idCounter}`;
|
||||
}
|
||||
|
||||
private async handle(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
if (this.options.failFirstRequest && !this.firstRequestServed) {
|
||||
this.firstRequestServed = true;
|
||||
res.writeHead(503, {
|
||||
"retry-after": "0",
|
||||
"content-type": "application/json",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ error: { code: "transient", message: "boom" } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.firstRequestServed = true;
|
||||
|
||||
if (req.headers.authorization !== "Bearer test-key") {
|
||||
res.writeHead(401, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({ error: { code: "unauthorized", message: "bad key" } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const method = req.method ?? "GET";
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
|
||||
try {
|
||||
if (segments[0] !== "v2") {
|
||||
return notFound(res);
|
||||
}
|
||||
// /v2/agents
|
||||
if (segments.length === 2 && segments[1] === "agents") {
|
||||
if (method === "POST") return this.createAgent(req, res);
|
||||
if (method === "GET") return this.listAgents(res);
|
||||
}
|
||||
if (segments.length === 3 && segments[1] === "agents") {
|
||||
const agent = this.agents.get(segments[2]);
|
||||
if (!agent) return notFound(res);
|
||||
if (method === "GET") return ok(res, this.serializeAgent(agent));
|
||||
if (method === "DELETE") {
|
||||
this.agents.delete(agent.id);
|
||||
return ok(res, { ok: true });
|
||||
}
|
||||
}
|
||||
if (
|
||||
segments.length === 4 &&
|
||||
segments[1] === "agents" &&
|
||||
segments[3] === "sessions"
|
||||
) {
|
||||
const agent = this.agents.get(segments[2]);
|
||||
if (!agent) return notFound(res);
|
||||
if (method === "POST") return this.createSession(agent, req, res);
|
||||
if (method === "GET")
|
||||
return ok(res, {
|
||||
items: [...agent.sessions.values()].map(serializeSession),
|
||||
});
|
||||
}
|
||||
if (
|
||||
segments.length === 5 &&
|
||||
segments[1] === "agents" &&
|
||||
segments[3] === "sessions"
|
||||
) {
|
||||
const agent = this.agents.get(segments[2]);
|
||||
const session = agent?.sessions.get(segments[4]);
|
||||
if (!session) return notFound(res);
|
||||
if (method === "GET") return ok(res, serializeSession(session));
|
||||
}
|
||||
// /v2/sessions/{sid}/...
|
||||
if (segments[1] === "sessions" && segments.length >= 3) {
|
||||
const session = this.findSession(segments[2]);
|
||||
if (!session) return notFound(res);
|
||||
|
||||
if (segments.length === 3) {
|
||||
if (method === "DELETE") {
|
||||
session.status = "terminated";
|
||||
return ok(res, { ok: true });
|
||||
}
|
||||
}
|
||||
if (
|
||||
segments.length === 4 &&
|
||||
segments[3] === "runs" &&
|
||||
method === "POST"
|
||||
) {
|
||||
return this.createRun(session, req, res);
|
||||
}
|
||||
if (
|
||||
segments.length === 4 &&
|
||||
segments[3] === "runs" &&
|
||||
method === "GET"
|
||||
) {
|
||||
return ok(res, {
|
||||
items: [...session.runs.values()].map(serializeRun),
|
||||
});
|
||||
}
|
||||
if (
|
||||
segments.length === 4 &&
|
||||
segments[3] === "followup" &&
|
||||
method === "POST"
|
||||
) {
|
||||
const body = await readJson(req);
|
||||
// Wire format: {prompt: {text: "..."}} per FollowupCreate.
|
||||
const text = body?.prompt?.text ?? "";
|
||||
session.followups.push(text);
|
||||
session.conversation.push({
|
||||
role: "user",
|
||||
content: text,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return ok(res, { ok: true });
|
||||
}
|
||||
if (
|
||||
segments.length === 4 &&
|
||||
segments[3] === "conversation" &&
|
||||
method === "GET"
|
||||
) {
|
||||
return ok(res, { turns: session.conversation.map(serializeTurn) });
|
||||
}
|
||||
if (segments.length === 5 && segments[3] === "runs") {
|
||||
const run = session.runs.get(segments[4]);
|
||||
if (!run) return notFound(res);
|
||||
if (method === "GET") return ok(res, serializeRun(run));
|
||||
}
|
||||
if (
|
||||
segments.length === 6 &&
|
||||
segments[3] === "runs" &&
|
||||
segments[5] === "events"
|
||||
) {
|
||||
const run = session.runs.get(segments[4]);
|
||||
if (!run) return notFound(res);
|
||||
return this.streamEvents(run, req, res, url);
|
||||
}
|
||||
if (
|
||||
segments.length === 6 &&
|
||||
segments[3] === "runs" &&
|
||||
segments[5] === "conversation"
|
||||
) {
|
||||
const run = session.runs.get(segments[4]);
|
||||
if (!run) return notFound(res);
|
||||
return ok(res, {
|
||||
turns: session.conversation
|
||||
.filter((t) => !t.runId || t.runId === run.id)
|
||||
.map(serializeTurn),
|
||||
});
|
||||
}
|
||||
if (
|
||||
segments.length === 6 &&
|
||||
segments[3] === "runs" &&
|
||||
segments[5] === "cancel" &&
|
||||
method === "POST"
|
||||
) {
|
||||
const run = session.runs.get(segments[4]);
|
||||
if (!run) return notFound(res);
|
||||
run.status = "cancelled";
|
||||
run.completedAt = new Date().toISOString();
|
||||
return ok(res, { ok: true });
|
||||
}
|
||||
}
|
||||
return notFound(res);
|
||||
} catch (e) {
|
||||
res.writeHead(500, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: { code: "internal", message: (e as Error).message },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createAgent(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
const body = await readJson(req);
|
||||
const id = this.nextId("agt");
|
||||
const agent: AgentRecord = {
|
||||
id,
|
||||
name: body.name ?? "unnamed",
|
||||
model: body.model ?? { id: "test-model" },
|
||||
// Wire format is snake_case (Python idiom); SDK transforms camelCase
|
||||
// public API to snake_case before sending.
|
||||
systemPrompt: body.system_prompt,
|
||||
metadata: body.metadata ?? {},
|
||||
createdAt: new Date().toISOString(),
|
||||
sessions: new Map(),
|
||||
};
|
||||
this.agents.set(id, agent);
|
||||
return ok(res, this.serializeAgent(agent));
|
||||
}
|
||||
|
||||
private listAgents(res: ServerResponse): void {
|
||||
return ok(res, {
|
||||
items: [...this.agents.values()].map((a) => this.serializeAgent(a)),
|
||||
});
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
agent: AgentRecord,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
await readJson(req);
|
||||
const id = this.nextId("ses");
|
||||
const vmId = this.nextId("vm");
|
||||
const session: SessionRecord = {
|
||||
id,
|
||||
agentId: agent.id,
|
||||
status: "ready",
|
||||
vmId,
|
||||
createdAt: new Date().toISOString(),
|
||||
runs: new Map(),
|
||||
followups: [],
|
||||
conversation: [],
|
||||
};
|
||||
agent.sessions.set(id, session);
|
||||
return ok(res, serializeSession(session));
|
||||
}
|
||||
|
||||
private async createRun(
|
||||
session: SessionRecord,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
const body = await readJson(req);
|
||||
// 409 if any non-terminal run exists.
|
||||
for (const r of session.runs.values()) {
|
||||
if (r.status === "queued" || r.status === "running") {
|
||||
res.writeHead(409, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: { code: "session_busy", message: "run in flight" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const id = this.nextId("run");
|
||||
const run: RunRecord = {
|
||||
id,
|
||||
sessionId: session.id,
|
||||
status: "queued",
|
||||
result: null,
|
||||
events: [],
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
sseClients: new Set(),
|
||||
};
|
||||
session.runs.set(id, run);
|
||||
session.conversation.push({
|
||||
role: "user",
|
||||
content: body.text ?? "",
|
||||
runId: id,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return ok(res, serializeRun(run));
|
||||
}
|
||||
|
||||
private streamEvents(
|
||||
run: RunRecord,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
): void {
|
||||
const startingSeq = Number(url.searchParams.get("starting_seq") ?? -1);
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
|
||||
// Replay events from startingSeq forward.
|
||||
const replayFrom =
|
||||
Number.isFinite(startingSeq) && startingSeq >= 0 ? startingSeq : 0;
|
||||
for (const event of run.events) {
|
||||
if (event.seq < replayFrom) continue;
|
||||
res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
|
||||
if (
|
||||
run.status === "finished" ||
|
||||
run.status === "error" ||
|
||||
run.status === "cancelled"
|
||||
) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
run.sseClients.add(res);
|
||||
const drop = !this.firstStreamDropped && this.options.killSseAfterMs;
|
||||
if (drop) {
|
||||
this.firstStreamDropped = true;
|
||||
setTimeout(() => {
|
||||
// Force-close socket without flushing any final event.
|
||||
req.socket.destroy();
|
||||
run.sseClients.delete(res);
|
||||
}, this.options.killSseAfterMs);
|
||||
}
|
||||
req.on("close", () => {
|
||||
run.sseClients.delete(res);
|
||||
});
|
||||
}
|
||||
|
||||
private serializeAgent(agent: AgentRecord) {
|
||||
// Wire format is snake_case; SDK transforms back to camelCase on receive.
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
model: agent.model,
|
||||
created_at: agent.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSession(s: SessionRecord) {
|
||||
return {
|
||||
id: s.id,
|
||||
agent_id: s.agentId,
|
||||
status: s.status,
|
||||
created_at: s.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeRun(r: RunRecord) {
|
||||
return {
|
||||
id: r.id,
|
||||
session_id: r.sessionId,
|
||||
status: r.status,
|
||||
result: r.result,
|
||||
started_at: r.startedAt,
|
||||
completed_at: r.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeTurn(t: {
|
||||
role: string;
|
||||
content: string;
|
||||
runId?: string;
|
||||
createdAt: string;
|
||||
}) {
|
||||
return {
|
||||
role: t.role,
|
||||
content: t.content,
|
||||
run_id: t.runId,
|
||||
created_at: t.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function ok(res: ServerResponse, body: unknown): void {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function notFound(res: ServerResponse): void {
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({ error: { code: "not_found", message: "not found" } }),
|
||||
);
|
||||
}
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
if (!raw) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
73
sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts
Normal file
73
sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Validation #2 — Round-trip against noop proxy.
|
||||
*
|
||||
* Verifies: Agent.create -> agent.createSession -> session.send -> for await
|
||||
* stream -> session.getRun.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Agent } from "../src/index.js";
|
||||
import { MockProxy } from "./mock-proxy.js";
|
||||
|
||||
describe("noop round-trip", () => {
|
||||
let proxy: MockProxy;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
proxy = new MockProxy();
|
||||
baseUrl = await proxy.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
it("creates an agent, opens a session, runs a prompt, and streams events to completion", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "round-tripper",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
|
||||
expect(agent.id).toMatch(/^agt_/);
|
||||
expect(agent.name).toBe("round-tripper");
|
||||
|
||||
const session = await agent.createSession({
|
||||
repos: [{ url: "https://github.com/example/repo", startingRef: "main" }],
|
||||
});
|
||||
expect(session.id).toMatch(/^ses_/);
|
||||
expect(session.agentId).toBe(agent.id);
|
||||
|
||||
const run = await session.send("hello world");
|
||||
expect(run.id).toMatch(/^run_/);
|
||||
expect(run.sessionId).toBe(session.id);
|
||||
|
||||
// Drive the run from the proxy side.
|
||||
const driver = (async () => {
|
||||
// Tiny delay so the SDK is connected to /events before we emit.
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
proxy.emit(run.id, "delta", { text: "hi " });
|
||||
proxy.emit(run.id, "delta", { text: "there" });
|
||||
proxy.complete(run.id, "hi there");
|
||||
})();
|
||||
|
||||
const collected: { type: string; data: unknown; seq: number }[] = [];
|
||||
for await (const ev of run.stream()) {
|
||||
collected.push({ type: ev.type, data: ev.data, seq: ev.seq });
|
||||
}
|
||||
await driver;
|
||||
|
||||
// Sanity: at least both deltas + done arrived in seq order.
|
||||
expect(collected.map((e) => e.type)).toContain("delta");
|
||||
expect(collected.at(-1)?.type).toBe("done");
|
||||
const seqs = collected.map((e) => e.seq);
|
||||
expect(seqs).toEqual([...seqs].sort((a, b) => a - b));
|
||||
|
||||
// Re-fetch the run by ID and confirm the terminal status.
|
||||
const fetched = await session.getRun(run.id);
|
||||
expect(fetched.id).toBe(run.id);
|
||||
expect(fetched.status).toBe("finished");
|
||||
expect(fetched.result).toBe("hi there");
|
||||
});
|
||||
});
|
||||
123
sdks/typescript-agent-sdk/tests/run.test.ts
Normal file
123
sdks/typescript-agent-sdk/tests/run.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Spec tests for `Run` (stream/wait/cancel/conversation).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Agent } from "../src/index.js";
|
||||
import { MockProxy } from "./mock-proxy.js";
|
||||
|
||||
describe("Run", () => {
|
||||
let proxy: MockProxy;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
proxy = new MockProxy();
|
||||
baseUrl = await proxy.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
it("wait() resolves once the run is in a terminal state", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "r",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send("go");
|
||||
|
||||
// Complete asynchronously so wait() actually has to poll.
|
||||
setTimeout(() => proxy.complete(run.id, "ok"), 50);
|
||||
|
||||
const result = await run.wait();
|
||||
expect(result.id).toBe(run.id);
|
||||
expect(result.status).toBe("finished");
|
||||
expect(result.result).toBe("ok");
|
||||
});
|
||||
|
||||
it("stream() yields events in seq order and ends on `done`", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "r",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send("go");
|
||||
|
||||
const driver = (async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
proxy.emit(run.id, "delta", { text: "1" });
|
||||
proxy.emit(run.id, "delta", { text: "2" });
|
||||
proxy.complete(run.id, "12");
|
||||
})();
|
||||
|
||||
const seqs: number[] = [];
|
||||
const types: string[] = [];
|
||||
for await (const ev of run.stream()) {
|
||||
seqs.push(ev.seq);
|
||||
types.push(ev.type);
|
||||
}
|
||||
await driver;
|
||||
expect(seqs).toEqual([...seqs].sort((a, b) => a - b));
|
||||
expect(types.at(-1)).toBe("done");
|
||||
});
|
||||
|
||||
it("cancel() flips status server-side and is a no-op once terminal", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "r",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send("go");
|
||||
await run.cancel();
|
||||
|
||||
const proxyRun = proxy.findRun(run.id);
|
||||
expect(proxyRun?.status).toBe("cancelled");
|
||||
// Calling again should be a no-op (not throw).
|
||||
await expect(run.cancel()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("session.conversation() returns the turn list including the run's user message", async () => {
|
||||
// `Run.conversation()` was removed — there is no per-run backend endpoint.
|
||||
// Conversation history is session-scoped; callers use SessionHandle instead.
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "r",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
await session.send("hello run");
|
||||
const turns = await session.conversation();
|
||||
expect(
|
||||
turns.some((t) => t.content === "hello run" && t.role === "user"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stream() respects an AbortSignal", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "r",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send("go");
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 30);
|
||||
|
||||
const collected: number[] = [];
|
||||
for await (const ev of run.stream({ signal: ctrl.signal })) {
|
||||
collected.push(ev.seq);
|
||||
}
|
||||
// Aborts cleanly without throwing.
|
||||
expect(Array.isArray(collected)).toBe(true);
|
||||
});
|
||||
});
|
||||
137
sdks/typescript-agent-sdk/tests/session.test.ts
Normal file
137
sdks/typescript-agent-sdk/tests/session.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Spec tests for `SessionHandle`.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Agent } from "../src/index.js";
|
||||
import { MockProxy } from "./mock-proxy.js";
|
||||
|
||||
describe("SessionHandle", () => {
|
||||
let proxy: MockProxy;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
proxy = new MockProxy();
|
||||
baseUrl = await proxy.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
it("send() accepts a string and starts a new run", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "s",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send("hello");
|
||||
expect(run.id).toMatch(/^run_/);
|
||||
expect(run.sessionId).toBe(session.id);
|
||||
expect(run.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("send() accepts a structured input with images", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "s",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send({
|
||||
text: "describe this",
|
||||
images: [{ url: "https://example.com/cat.png" }],
|
||||
});
|
||||
expect(run.id).toMatch(/^run_/);
|
||||
});
|
||||
|
||||
it("send() returns a 409 if a run is already in flight", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "s",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
await session.send("first");
|
||||
await expect(
|
||||
session.send({ text: "second", images: [] })
|
||||
).rejects.toMatchObject({ name: "LiteLLMAgentError", status: 409 });
|
||||
});
|
||||
|
||||
it("getRun() round-trips a run by ID", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "s",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const created = await session.send("yo");
|
||||
const fetched = await session.getRun(created.id);
|
||||
expect(fetched.id).toBe(created.id);
|
||||
expect(fetched.sessionId).toBe(session.id);
|
||||
});
|
||||
|
||||
it("listRuns() returns runs created in this session", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "s",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const r1 = await session.send("one");
|
||||
proxy.complete(r1.id, "done one");
|
||||
const r2 = await session.send("two");
|
||||
proxy.complete(r2.id, "done two");
|
||||
|
||||
const listed = await session.listRuns();
|
||||
const ids = new Set(listed.items.map((r) => r.id));
|
||||
expect(ids).toEqual(new Set([r1.id, r2.id]));
|
||||
});
|
||||
|
||||
it("conversation() returns turns recorded by the proxy", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "s",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
await session.send("first message");
|
||||
const turns = await session.conversation();
|
||||
expect(turns.some((t) => t.role === "user" && t.content === "first message")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("delete() flips status to terminated server-side", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "s",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
await session.delete();
|
||||
const proxySession = proxy.findSession(session.id);
|
||||
expect(proxySession?.status).toBe("terminated");
|
||||
expect(session.status).toBe("terminated");
|
||||
});
|
||||
|
||||
it("terminate() is an alias for delete()", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "s",
|
||||
model: { id: "m" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
await session.terminate();
|
||||
expect(proxy.findSession(session.id)?.status).toBe("terminated");
|
||||
});
|
||||
});
|
||||
99
sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts
Normal file
99
sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Validation #4 — SSE auto-reconnect.
|
||||
*
|
||||
* Mid-stream, the mock proxy kills the underlying socket. The SDK should
|
||||
* reconnect with `?starting_seq=N` transparently and not lose or duplicate
|
||||
* events.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Agent } from "../src/index.js";
|
||||
import { MockProxy } from "./mock-proxy.js";
|
||||
|
||||
describe("SSE auto-reconnect", () => {
|
||||
let proxy: MockProxy;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
proxy = new MockProxy({ killSseAfterMs: 60 });
|
||||
baseUrl = await proxy.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
it("transparently reconnects mid-stream and preserves event order without loss or duplication", async () => {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl,
|
||||
name: "resumer",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send("kick off");
|
||||
|
||||
// Emit two events before the mock kills the socket, then continue.
|
||||
const driver = (async () => {
|
||||
// Wait for the SSE connection to open.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
proxy.emit(run.id, "delta", { text: "alpha" });
|
||||
proxy.emit(run.id, "delta", { text: "beta" });
|
||||
// Kill happens at ~60ms after first connect.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
proxy.emit(run.id, "delta", { text: "gamma" });
|
||||
proxy.complete(run.id, "alphabetagamma");
|
||||
})();
|
||||
|
||||
const seenSeqs: number[] = [];
|
||||
const seenTexts: string[] = [];
|
||||
for await (const ev of run.stream()) {
|
||||
seenSeqs.push(ev.seq);
|
||||
if (ev.type === "delta") {
|
||||
seenTexts.push((ev.data as { text: string }).text);
|
||||
}
|
||||
}
|
||||
await driver;
|
||||
|
||||
// Each seq seen exactly once.
|
||||
expect(new Set(seenSeqs).size).toBe(seenSeqs.length);
|
||||
// Strictly increasing — no out-of-order replays leaked through.
|
||||
for (let i = 1; i < seenSeqs.length; i++) {
|
||||
expect(seenSeqs[i]).toBeGreaterThan(seenSeqs[i - 1]);
|
||||
}
|
||||
// All three deltas present (none lost).
|
||||
expect(seenTexts).toEqual(["alpha", "beta", "gamma"]);
|
||||
});
|
||||
|
||||
it("respects an explicit startingSeq on first connect", async () => {
|
||||
// Disable mid-stream kill so we test the resume-from-N path only.
|
||||
const proxy2 = new MockProxy();
|
||||
const url2 = await proxy2.start();
|
||||
try {
|
||||
const agent = await Agent.create({
|
||||
apiKey: "test-key",
|
||||
baseUrl: url2,
|
||||
name: "resume-explicit",
|
||||
model: { id: "test-model" },
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const run = await session.send("go");
|
||||
|
||||
// Pre-populate events so they exist when stream() opens.
|
||||
proxy2.emit(run.id, "delta", { text: "0" });
|
||||
proxy2.emit(run.id, "delta", { text: "1" });
|
||||
proxy2.emit(run.id, "delta", { text: "2" });
|
||||
proxy2.complete(run.id, "012");
|
||||
|
||||
const seqs: number[] = [];
|
||||
for await (const ev of run.stream({ startingSeq: 2 })) {
|
||||
seqs.push(ev.seq);
|
||||
}
|
||||
// Should not see 0 or 1 — only seq >= 2 (delta) and the done event (seq 3).
|
||||
expect(seqs.every((s) => s >= 2)).toBe(true);
|
||||
expect(seqs).toContain(2);
|
||||
} finally {
|
||||
await proxy2.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
179
sdks/typescript-agent-sdk/tests/wire-transform.test.ts
Normal file
179
sdks/typescript-agent-sdk/tests/wire-transform.test.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Unit tests for the snake_case ↔ camelCase wire transform.
|
||||
*
|
||||
* The SDK's public API is camelCase but the LiteLLM proxy speaks snake_case.
|
||||
* `snakeToCamel` runs over response JSON; `camelToSnake` runs over request
|
||||
* bodies. Only object keys are rewritten — values pass through unchanged.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { camelToSnake, snakeToCamel } from "../src/client/http.js";
|
||||
|
||||
describe("snakeToCamel", () => {
|
||||
it("renames top-level snake_case keys", () => {
|
||||
expect(snakeToCamel({ agent_id: "x", created_at: "t" })).toEqual({
|
||||
agentId: "x",
|
||||
createdAt: "t",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes single-word keys through unchanged", () => {
|
||||
expect(snakeToCamel({ id: "x", type: "delta", data: 1, seq: 7 })).toEqual({
|
||||
id: "x",
|
||||
type: "delta",
|
||||
data: 1,
|
||||
seq: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("recurses into nested objects", () => {
|
||||
expect(
|
||||
snakeToCamel({
|
||||
outer_field: { inner_field: { leaf_key: "v" } },
|
||||
}),
|
||||
).toEqual({
|
||||
outerField: { innerField: { leafKey: "v" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("recurses into arrays of objects", () => {
|
||||
expect(
|
||||
snakeToCamel({
|
||||
items_list: [
|
||||
{ item_id: 1, sub_field: "a" },
|
||||
{ item_id: 2, sub_field: "b" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
itemsList: [
|
||||
{ itemId: 1, subField: "a" },
|
||||
{ itemId: 2, subField: "b" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves values verbatim (does not transform string values that look snake_case)", () => {
|
||||
expect(snakeToCamel({ system_prompt: "hello_world" })).toEqual({
|
||||
systemPrompt: "hello_world",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles keys with embedded digits", () => {
|
||||
expect(snakeToCamel({ agent_v2_id: "x" })).toEqual({ agentV2Id: "x" });
|
||||
});
|
||||
|
||||
it("returns primitives unchanged", () => {
|
||||
expect(snakeToCamel(null)).toBe(null);
|
||||
expect(snakeToCamel(undefined)).toBe(undefined);
|
||||
expect(snakeToCamel("snake_string")).toBe("snake_string");
|
||||
expect(snakeToCamel(42)).toBe(42);
|
||||
expect(snakeToCamel(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles arrays at the top level", () => {
|
||||
expect(snakeToCamel([{ a_b: 1 }, { a_b: 2 }])).toEqual([
|
||||
{ aB: 1 },
|
||||
{ aB: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("camelToSnake", () => {
|
||||
it("renames top-level camelCase keys", () => {
|
||||
expect(camelToSnake({ agentId: "x", createdAt: "t" })).toEqual({
|
||||
agent_id: "x",
|
||||
created_at: "t",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes single-word keys through unchanged", () => {
|
||||
expect(camelToSnake({ id: "x", type: "delta", data: 1, seq: 7 })).toEqual({
|
||||
id: "x",
|
||||
type: "delta",
|
||||
data: 1,
|
||||
seq: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("recurses into nested objects", () => {
|
||||
expect(
|
||||
camelToSnake({
|
||||
outerField: { innerField: { leafKey: "v" } },
|
||||
}),
|
||||
).toEqual({
|
||||
outer_field: { inner_field: { leaf_key: "v" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("recurses into arrays of objects", () => {
|
||||
expect(
|
||||
camelToSnake({
|
||||
itemsList: [
|
||||
{ itemId: 1, subField: "a" },
|
||||
{ itemId: 2, subField: "b" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
items_list: [
|
||||
{ item_id: 1, sub_field: "a" },
|
||||
{ item_id: 2, sub_field: "b" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves string values verbatim", () => {
|
||||
expect(camelToSnake({ systemPrompt: "helloWorld" })).toEqual({
|
||||
system_prompt: "helloWorld",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles keys with embedded digits", () => {
|
||||
expect(camelToSnake({ agentV2Id: "x" })).toEqual({ agent_v2_id: "x" });
|
||||
});
|
||||
|
||||
it("returns primitives unchanged", () => {
|
||||
expect(camelToSnake(null)).toBe(null);
|
||||
expect(camelToSnake(undefined)).toBe(undefined);
|
||||
expect(camelToSnake("camelString")).toBe("camelString");
|
||||
expect(camelToSnake(42)).toBe(42);
|
||||
expect(camelToSnake(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip", () => {
|
||||
it("camelToSnake → snakeToCamel preserves key names for typical SDK shapes", () => {
|
||||
const original = {
|
||||
agentId: "agt_1",
|
||||
createdAt: "2026-05-06",
|
||||
systemPrompt: "be helpful",
|
||||
nestedObject: { innerField: "v", anotherField: 7 },
|
||||
itemsList: [{ itemId: 1 }, { itemId: 2 }],
|
||||
// Single-word keys stay stable.
|
||||
id: "x",
|
||||
type: "delta",
|
||||
data: { foo: "bar" },
|
||||
seq: 0,
|
||||
status: "ready",
|
||||
};
|
||||
const round = snakeToCamel(camelToSnake(original));
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("snakeToCamel → camelToSnake preserves key names for typical wire shapes", () => {
|
||||
const original = {
|
||||
agent_id: "agt_1",
|
||||
created_at: "2026-05-06",
|
||||
system_prompt: "be helpful",
|
||||
nested_object: { inner_field: "v", another_field: 7 },
|
||||
items_list: [{ item_id: 1 }, { item_id: 2 }],
|
||||
next_cursor: null,
|
||||
id: "x",
|
||||
type: "delta",
|
||||
data: { foo: "bar" },
|
||||
seq: 0,
|
||||
status: "ready",
|
||||
};
|
||||
const round = camelToSnake(snakeToCamel(original));
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
});
|
||||
26
sdks/typescript-agent-sdk/tsconfig.json
Normal file
26
sdks/typescript-agent-sdk/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests", "examples"]
|
||||
}
|
||||
9
sdks/typescript-agent-sdk/vitest.config.ts
Normal file
9
sdks/typescript-agent-sdk/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 15_000,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue