<!-- OpenSmartRoute: TypeScript SDK. https://opensmartroute.ai/docs/sdk-typescript -->
# @opensmartroute/sdk

TypeScript client for [OpenSmartRoute](https://opensmartroute.ai): the hosted platform
(`https://opensmartroute.ai`) and a self-hosted `osr serve`. Zero runtime dependencies - it uses
the global `fetch`, so it runs on Node 20+, Deno, Bun, Cloudflare Workers, Vercel Edge and in browsers.

```bash
npm install @opensmartroute/sdk        # pnpm add / yarn add / bun add work the same
```

The package version follows the OpenSmartRoute release (`1.3.0` today): every release publishes it to the
npm registry, and the types under `src/generated` are generated from that release's `platform/api/openapi.json`.
ESM and CommonJS entry points with bundled `.d.ts`; `import { OpenSmartRoute } from "@opensmartroute/sdk"`
or `const { OpenSmartRoute } = require("@opensmartroute/sdk")`.

## Quick start: route, then report

```ts
import { OpenSmartRoute } from "@opensmartroute/sdk";

const osr = new OpenSmartRoute({
  baseUrl: "https://opensmartroute.ai",
  apiKey: process.env.OSR_API_KEY!,
});

const decision = await osr.route({
  text: "Refund order #123, the customer is upset",
  objective: { cost: 0.4, quality: 0.6 },
  constraints: { data_boundary: "private" },
  top_k: 3,
});
console.log(decision.target.id, decision.confidence, decision.explanation);

// ...call the chosen target, then close the loop so the learners improve on your traffic
await osr.feedback({
  request_id: decision.request_id,
  target_id: decision.target.id,
  success: true,
  quality: 0.9,
  latency_ms: 850,
});
```

`route({ ..., execute: true })` also runs the target on the platform and returns the answer in
`decision.result` (`text`, `provider`, `cost_usd`, `fallback_from`). `plan: true` composes a
persona -> skill -> model plan, plus the tools the model may call (`decision.plan`). `estimate(req)` quotes a request before sending it;
`targets()` lists the catalogue; `me()`, `quota()` and `info()` describe the account, where it stands against its
monthly request quota, and the deployment.

## OpenAI-compatible chat, streaming and embeddings

The same client speaks the platform's `/v1` surface. `model: "auto"` (the default) lets the router
choose; a target id pins one; `models: [...]` gives the router a candidate list with fallbacks.
Every answer carries the routing decision in `opensmartroute` (typed as `OsrMetadata`).

```ts
const reply = await osr.chat.completions.create({
  model: "auto",
  messages: [{ role: "user", content: "Write a haiku about routing." }],
  osr: { objective: { cost: 0.7 }, fallbacks: true },
});
console.log(reply.model, reply.choices[0].message.content);
console.log(reply.opensmartroute.target, reply.opensmartroute.confidence, reply.opensmartroute.cost_usd);

// streaming: an AsyncIterable of chat.completion.chunk objects, parsed from the SSE stream
const stream = await osr.chat.completions.create({
  messages: [{ role: "user", content: "Explain LinUCB in two sentences." }],
  stream: true,
});
console.log("answering:", stream.target); // known from the X-OSR-Target header before the first token
for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta.content ?? "");
  if (chunk.opensmartroute) console.log("\n", chunk.usage, chunk.opensmartroute); // the last chunk
}
// or: const completion = await stream.finalCompletion();

const vectors = await osr.embeddings.create({ input: ["first text", "second text"] });
console.log(vectors.data[0].embedding.length, vectors.opensmartroute.target);

const models = await osr.models.list(); // "auto", "osr/auto:cheap", ... and every executable target
```

## Responses API, Messages API, speech, transcription, images

The OpenAI **Responses API** (what the OpenAI Agents SDK speaks) and the Anthropic **Messages API** (what the
Anthropic SDKs and Claude Code speak) run over the same routed path; media endpoints route among the targets
that can do the job:

```ts
const r = await osr.responses.create({ input: "Plan a 3-day trip to Oslo", instructions: "Be concise." });
console.log(responseText(r), r.opensmartroute?.target);
const s = await osr.responses.create({ input: "Explain LinUCB", stream: true });
for await (const ev of s) if (ev.type === "response.output_text.delta") process.stdout.write(ev.delta!);
const final = await s.finalResponse();

const m = await osr.messages.create({ max_tokens: 256, messages: [{ role: "user", content: "Explain LinUCB" }] });
console.log(messageText(m), m.stop_reason);
const ms = await osr.messages.create({ max_tokens: 256, messages: [{ role: "user", content: "hi" }], stream: true });
const done = await ms.finalMessage(); // text and tool_use blocks assembled from the events
const { input_tokens } = await osr.messages.countTokens({ messages: [{ role: "user", content: "hi" }] });

const speech = await osr.audio.speech.create({ input: "Hello there", response_format: "mp3" }); // bytes + headers
const text = await osr.audio.transcriptions.create({ file: audioBlob, filename: "memo.wav" });
const pic = await osr.images.generate({ prompt: "a lighthouse at dawn", size: "1024x1024" });
```

## Workspace management (platform)

Every route of the platform's OpenAPI document is reachable, typed from the generated schema, through
`osr.rest` - paths, path / query parameters, bodies and responses are checked by `tsc`:

```ts
const keys = await osr.rest.get("/api/v1/keys");
await osr.rest.post("/api/v1/keys", { body: { name: "ci", scopes: ["route", "feedback"], expires_in_days: 90 } });
await osr.rest.del("/api/v1/keys/{key_id}", { path: { key_id: keys[0].id } });
```

Convenience groups sit on top of it: `osr.keys` (list / create / rename / rotate / revoke), `osr.tenants`,
`osr.governance` (policy), `osr.providers` (bring your own model providers), `osr.notifications`,
`osr.support`, `osr.handoffs`, `osr.billing`, `osr.marketplace` and `osr.insights` (usage, activity, savings,
audit trail, statistics, rankings, SLA).

## Against `osr serve`

`osr serve` mounts the same routes at the root (no `/api/v1` prefix) and takes a bearer token:

```bash
osr -t targets.yaml -r rules.yaml serve --port 8000 --token my-secret-token
```

```ts
const local = new OpenSmartRoute({
  baseUrl: "http://localhost:8000",
  apiKey: "my-secret-token",
  apiPrefix: "", // osr serve: /route, /feedback, /targets; /v1/... is the same
});
const who = await local.whoami(); // { edition: "self-hosted", authenticated: true, ... }
const d = await local.route({ text: "Summarise this ticket" });
```

## Options and headers

| Option | Header / effect |
|---|---|
| `apiKey` | `Authorization: Bearer <key>` (platform API key, or an `osr serve` token) |
| `tenant` / `opts.tenant` | `X-OSR-Tenant` - the tenant's constraints and budget apply |
| `app` | `X-OSR-App` - attribution echoed in the decision metadata |
| `opts.requestId` | `X-Request-Id` - your own id, echoed back and usable for `feedback` |
| `headers`, `opts.headers` | extra headers for every / this request |
| `timeoutMs`, `opts.timeoutMs` | abort when no headers arrive in time (streams keep flowing) |
| `opts.signal` | your `AbortSignal` (also cancels a stream) |
| `fetch` | a custom `fetch` (tests, proxies, instrumentation) |

`requestWithResponse(method, path, body)` returns the decoded body **and** the `Response`, for
headers such as `X-OSR-Target`, `X-OSR-Trace-Id`, `ETag` or the quota headers.

## Errors

Every non-2xx answer raises `OpenSmartRouteError` with `status`, `detail` (the server's message),
`requestId` (`X-Request-Id` / `X-OSR-Request-Id`), `retryAfter` (seconds, from `Retry-After`),
`headers` and a stable `code`:

| Class | Status | `code` |
|---|---|---|
| `AuthenticationError` | 401 | `authentication` |
| `PermissionDeniedError` (`upgradeRequired` when `X-Upgrade: true`) | 403 | `permission` |
| `NotFoundError` | 404 | `not_found` |
| `RateLimitError` (`budget` from the `X-Budget-*` headers) | 429 | `rate_limit` |
| `OpenSmartRouteError` | 400 / 413 / 422 | `validation` |
| `OpenSmartRouteError` | 501 / 502-504 / other 5xx | `not_implemented` / `upstream` / `server` |
| `ConnectionError` | - (no response) | `network` |

```ts
import { RateLimitError } from "@opensmartroute/sdk";

try {
  await osr.route({ text: "..." });
} catch (e) {
  if (e instanceof RateLimitError) await sleep((e.retryAfter ?? 1) * 1000);
  else throw e;
}
```

## Types

`RouteIn`, `FeedbackIn`, `EstimateIn`, `ChatCompletionIn`, `EmbeddingsIn` and `RoutingOptions` are
generated from the platform's OpenAPI document (`src/generated/openapi.ts`, also exported as
`OpenAPIPaths` / `OpenAPIComponents` for `openapi-fetch` users). `RouteDecision`, `OsrMetadata`,
`ChatCompletion`, `ChatCompletionChunk`, `EmbeddingsResponse`, `ModelList`, `TargetInfo`, `Estimate`
and `Me` describe the answers.

## Development

```bash
npm ci
npm run generate   # regenerate src/generated/openapi.ts from ../api/openapi.json (commit the result)
npm run check      # eslint + tsc + vitest
npm run build      # dist/ (ESM + CJS + .d.ts) with tsup
```

The live test `tests/live.test.ts` runs only when `OSR_SDK_BASE_URL` and `OSR_SDK_API_KEY` are set
(`OSR_SDK_API_PREFIX` forces `/api/v1` or an empty prefix; without it the test probes `GET /whoami` to
recognise `osr serve`). Against a local server from the repository root:

```powershell
$env:PYTHONPATH = "src"
python -X utf8 -m opensmartroute -t examples/targets.yaml -r examples/rules.yaml serve --port 8765 --token sdk-test-token
# in another shell
$env:OSR_SDK_BASE_URL = "http://127.0.0.1:8765"; $env:OSR_SDK_API_KEY = "sdk-test-token"
npm test --prefix platform/sdk-ts
```

Without a model provider configured the chat and embeddings calls answer with a well-formed error
(the test accepts either outcome); routing, feedback, targets and models are asserted fully.

## License

Apache-2.0 - see LICENSE (`platform/sdk-ts/LICENSE`).
