Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 42 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# DoTs
`DoTs` is the official DigitalOcean Typescript Client based on the DO OpenAPIv3 specification.

> **New in v1.11.0** — `DoTs` now ships first-class support for DigitalOcean Serverless Inference: streaming chat completions, image generation, and model listing. Jump to [AI & Inference](#ai--inference) for usage, or browse runnable scripts in [`examples/inference/`](./examples/inference).
> **New** — Inference now lives in its own namespace: `import { Client } from "@digitalocean/dots/inference"`. The legacy `import { InferenceClient } from "@digitalocean/dots"` is still fully supported (`Client` and `InferenceClient` are the same constructor). Jump to [AI & Inference](#ai--inference) for usage.
>
> **New in v1.11.0** — `DoTs` ships first-class support for DigitalOcean Serverless Inference: streaming chat completions, image generation, and model listing. Browse runnable scripts in [`examples/inference/`](./examples/inference).

## Getting Started
#### Prerequisites
Expand Down Expand Up @@ -96,22 +98,22 @@ Inference APIs are gated by a separate credential from the v2 control-plane toke
- A **DigitalOcean Personal Access Token (PAT) with _full access_ scope** — read/write across all resources, including GenAI/Inference. Tokens scoped only to specific resource types will be rejected.
- A **Model Access Key** issued from the DigitalOcean Cloud console under *GenAI Platform → Model Access Keys*. These are the recommended credential for production workloads since they're scoped only to inference.

The credential is passed as `apiKey` to `InferenceClient`. The same `DIGITALOCEAN_TOKEN` env var used elsewhere in this README works here **only if** that PAT has full-access scope; otherwise use a Model Access Key.
The credential is passed as `apiKey` to the inference `Client`. The same `DIGITALOCEAN_TOKEN` env var used elsewhere in this README works here **only if** that PAT has full-access scope; otherwise use a Model Access Key.

```typescript
import { InferenceClient } from "@digitalocean/dots";
import { Client } from "@digitalocean/dots/inference";

const client = new InferenceClient({
const client = new Client({
apiKey: process.env.DIGITALOCEAN_TOKEN!, // full-access PAT or Model Access Key
});
```

### Streaming chat completions

```typescript
import { InferenceClient } from "@digitalocean/dots";
import { Client } from "@digitalocean/dots/inference";

const client = new InferenceClient({ apiKey: process.env.DIGITALOCEAN_TOKEN! });
const client = new Client({ apiKey: process.env.DIGITALOCEAN_TOKEN! });

const stream = await client.chat.completions.create({
model: "llama3.3-70b-instruct",
Expand Down Expand Up @@ -141,9 +143,9 @@ await client.chat.completions.create(
### Image generation

```typescript
import { InferenceClient } from "@digitalocean/dots";
import { Client } from "@digitalocean/dots/inference";

const client = new InferenceClient({ apiKey: process.env.DIGITALOCEAN_TOKEN! });
const client = new Client({ apiKey: process.env.DIGITALOCEAN_TOKEN! });

const result = await client.images.generate({
model: "stable-diffusion-3.5-large", // any image model from `client.models.list()`
Expand All @@ -166,25 +168,53 @@ if (img?.url) {
### Listing available models

```typescript
import { InferenceClient } from "@digitalocean/dots";
import { Client } from "@digitalocean/dots/inference";

const client = new InferenceClient({ apiKey: process.env.DIGITALOCEAN_TOKEN! });
const client = new Client({ apiKey: process.env.DIGITALOCEAN_TOKEN! });

const models = await client.models.list();
for (const m of models.data ?? []) {
console.log(`${m.id}\t${m.owned_by ?? ""}`);
}
```

Minimal, runnable versions of all three snippets live under [`examples/inference/`](./examples/inference):
### Import styles (all equivalent)

The new `@digitalocean/dots/inference` subpath supports all the usual import shapes — pick whichever matches your project's conventions. Every form below resolves to the **same** constructor (`Client === InferenceClient`), so you can mix and match without any runtime difference:

```typescript
// Named import — closest to `from pydo.inference import client`
import { Client } from "@digitalocean/dots/inference";

// Namespace import — gives you Client, ClientOptions, SSEStream, etc. in one bag
import * as inference from "@digitalocean/dots/inference";
const c = new inference.Client({ apiKey });

// Default import
import Client from "@digitalocean/dots/inference";

// Legacy name still reachable through the new subpath
import { InferenceClient } from "@digitalocean/dots/inference";

// Original root import — unchanged, still fully supported
import { InferenceClient } from "@digitalocean/dots";
```

> **Backward compatibility.** The legacy `import { InferenceClient } from "@digitalocean/dots"` continues to work exactly as before. No existing code needs to change. New endpoints added to the OpenAPI spec land automatically in both surfaces — you'll never need to edit `src/inference-gen/inference.ts` to expose them.

### Runnable examples

Minimal, runnable versions of every snippet above live under [`examples/inference/`](./examples/inference):

- [`namespace-usage.ts`](./examples/inference/namespace-usage.ts) — exercises the new `@digitalocean/dots/inference` namespace end-to-end (identity check, `models.list`, non-streaming chat, streaming chat, embeddings).
- [`chat-streaming.ts`](./examples/inference/chat-streaming.ts) — streams a chat completion and prints tokens as they arrive.
- [`image-generation.ts`](./examples/inference/image-generation.ts) — generates a single image with `stable-diffusion-3.5-large`. Prints the URL if the model returns one, otherwise decodes the base64 payload and writes `generated-image.png`.
- [`models-list.ts`](./examples/inference/models-list.ts) — lists the IDs of all models you can call.

Each script is a few lines long and only needs `DIGITALOCEAN_TOKEN` set:
Each script only needs `DIGITALOCEAN_TOKEN` set:

```shell
DIGITALOCEAN_TOKEN=... npx tsx examples/inference/namespace-usage.ts
DIGITALOCEAN_TOKEN=... npx tsx examples/inference/chat-streaming.ts
DIGITALOCEAN_TOKEN=... npx tsx examples/inference/image-generation.ts
DIGITALOCEAN_TOKEN=... npx tsx examples/inference/models-list.ts
Expand Down
13 changes: 11 additions & 2 deletions examples/inference/chat-streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,24 @@
* DIGITALOCEAN_TOKEN=... npx tsx examples/inference/chat-streaming.ts
*
* The token must be a full-access DigitalOcean PAT (or a Model Access Key).
*
* Imports use the new `@digitalocean/dots/inference` namespace. Inside this
* repo we use the relative-path equivalent; downstream consumers replace the
* relative path with `@digitalocean/dots/inference`:
*
* import { Client } from "@digitalocean/dots/inference";
*
* The legacy `import { InferenceClient } from "@digitalocean/dots"` form is
* still fully supported — Client and InferenceClient are the same constructor.
*/
import { InferenceClient } from "../../index.js";
import { Client } from "../../src/inference-gen/inference.js";

const apiKey = process.env.DIGITALOCEAN_TOKEN;
if (!apiKey) {
throw new Error("DIGITALOCEAN_TOKEN not set");
}

const client = new InferenceClient({ apiKey });
const client = new Client({ apiKey });

const stream = await client.chat.completions.create({
model: "llama3.3-70b-instruct",
Expand Down
12 changes: 10 additions & 2 deletions examples/inference/image-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@
* DIGITALOCEAN_TOKEN=... npx tsx examples/inference/image-generation.ts
*
* The token must be a full-access DigitalOcean PAT (or a Model Access Key).
*
* Imports use the new `@digitalocean/dots/inference` namespace. Downstream
* consumers (after publish) write:
*
* import { Client } from "@digitalocean/dots/inference";
*
* The legacy `import { InferenceClient } from "@digitalocean/dots"` import is
* still supported — Client and InferenceClient are the same constructor.
*/
import { InferenceClient } from "../../index.js";
import { Client } from "../../src/inference-gen/inference.js";

const apiKey = process.env.DIGITALOCEAN_TOKEN;
if (!apiKey) {
throw new Error("DIGITALOCEAN_TOKEN not set");
}

const client = new InferenceClient({ apiKey });
const client = new Client({ apiKey });

const result = await client.images.generate({
model: "stable-diffusion-3.5-large",
Expand Down
12 changes: 10 additions & 2 deletions examples/inference/models-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@
* DIGITALOCEAN_TOKEN=... npx tsx examples/inference/models-list.ts
*
* The token must be a full-access DigitalOcean PAT (or a Model Access Key).
*
* Imports use the new `@digitalocean/dots/inference` namespace. Downstream
* consumers (after publish) write:
*
* import { Client } from "@digitalocean/dots/inference";
*
* The legacy `import { InferenceClient } from "@digitalocean/dots"` import is
* still supported — Client and InferenceClient are the same constructor.
*/
import { InferenceClient } from "../../index.js";
import { Client } from "../../src/inference-gen/inference.js";

const apiKey = process.env.DIGITALOCEAN_TOKEN;
if (!apiKey) {
throw new Error("DIGITALOCEAN_TOKEN not set");
}

const client = new InferenceClient({ apiKey });
const client = new Client({ apiKey });

const models = await client.models.list();
for (const m of models.data ?? []) {
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
"version": "1.16.0",
"description": "TypeScript client generator based on DigitalOcean's OpenAPI specification.",
"main": "index.js",
"exports": {
".": "./index.js",
"./inference": "./src/inference-gen/inference.js",
"./package.json": "./package.json",
"./*": "./*"
},
"scripts": {
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test:mock": "node --experimental-vm-modules node_modules/.bin/jest --testPathPatterns=mock",
Expand Down
6 changes: 6 additions & 0 deletions scripts/postgen-inference.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
* 1. src/inference-gen/index.ts — paths, base URL, factory (Kiota-based, for v1.* usage)
* 2. src/inference-gen/InferenceClient.ts — OpenAI-compatible wrapper (standalone, no Kiota)
*
* NOTE: src/inference-gen/inference.ts is the public namespace barrel for the
* `@digitalocean/dots/inference` subpath export. It is HAND-WRITTEN and must
* NOT be regenerated here — it only re-exports `*` from InferenceClient.ts and
* index.ts, so any new endpoint picked up by this script is automatically
* reachable through that namespace with no edits required.
*
* The InferenceClient uses direct fetch() — NOT Kiota — so responses keep their
* native snake_case field names matching the OpenAI Node SDK exactly.
*
Expand Down
51 changes: 51 additions & 0 deletions src/inference-gen/inference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Public namespace barrel for the `@digitalocean/dots/inference` subpath.
*
* This file is HAND-WRITTEN and intentionally kept thin. It re-exports the
* auto-generated `InferenceClient` (regenerated from the OpenAPI spec by
* `scripts/postgen-inference.mjs`) under cleaner namespace-local names so
* consumers can write:
*
* import { Client } from "@digitalocean/dots/inference";
* const c = new Client({ apiKey: process.env.MODEL_ACCESS_KEY });
* await c.chat.completions.create({ model, messages });
*
* Or namespace-style:
*
* import * as inference from "@digitalocean/dots/inference";
* const c = new inference.Client({ apiKey });
*
* Or default-style (mirrors the existing root default export):
*
* import Client from "@digitalocean/dots/inference";
*
* Every existing import path continues to work unchanged:
*
* import { InferenceClient } from "@digitalocean/dots"; // legacy
* import { InferenceClient } from "@digitalocean/dots/inference"; // also works
*
* Auto-inclusion: because this barrel re-exports `*` from the regenerated
* `InferenceClient`, any new endpoint added to the OpenAPI spec (and picked
* up by `postgen-inference.mjs` on the next `make generate`) is automatically
* reachable as `inference.Client.<group>.<method>(...)` with no edits here.
*/

export {
InferenceClient,
InferenceClient as Client,
SSEStream,
type InferenceClientOptions,
type InferenceClientOptions as ClientOptions,
type InferenceStreamCallbacks,
type InferenceStreamCallbacks as StreamCallbacks,
} from "./InferenceClient.js";

export {
createDigitalOceanInferenceClient,
createDigitalOceanInferenceClient as createClient,
DEFAULT_INFERENCE_BASE_URL,
INFERENCE_OPENAPI_PATHS,
normalizeInferenceBaseUrl,
} from "./index.js";

export { default } from "./InferenceClient.js";
95 changes: 95 additions & 0 deletions tests/mocked/inference-namespace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import nock from "nock";

/**
* Verifies the new `@digitalocean/dots/inference` namespace barrel:
* - `Client` is the same constructor as the legacy `InferenceClient`
* (so old code using `InferenceClient` keeps working byte-for-byte)
* - `SSEStream`, type aliases, and helpers are reachable through the barrel
* - A `new Client(...).chat.completions.create(...)` call routes to the
* same `inference.do-ai.run` endpoint as the legacy surface, so any new
* endpoint generated by `scripts/postgen-inference.mjs` is automatically
* reachable here without further edits.
*/

import * as inference from "../../src/inference-gen/inference.js";
import {
Client,
SSEStream,
DEFAULT_INFERENCE_BASE_URL,
INFERENCE_OPENAPI_PATHS,
createClient,
type ClientOptions,
type StreamCallbacks,
} from "../../src/inference-gen/inference.js";
import LegacyDefault from "../../src/inference-gen/inference.js";
import LegacyInferenceClient, {
InferenceClient as NamedLegacyInferenceClient,
} from "../../src/inference-gen/InferenceClient.js";

const BASE = "https://inference.do-ai.run";

describe("@digitalocean/dots/inference namespace", () => {
afterEach(() => nock.cleanAll());

it("Client is the same constructor as legacy InferenceClient", () => {
expect(Client).toBe(LegacyInferenceClient);
expect(Client).toBe(NamedLegacyInferenceClient);
expect(inference.Client).toBe(LegacyInferenceClient);
expect(inference.InferenceClient).toBe(LegacyInferenceClient);
});

it("default export is the Client class", () => {
expect(LegacyDefault).toBe(LegacyInferenceClient);
});

it("re-exports SSEStream, helpers, and constants", () => {
expect(SSEStream).toBeDefined();
expect(DEFAULT_INFERENCE_BASE_URL).toBe(BASE);
expect(Array.isArray(INFERENCE_OPENAPI_PATHS)).toBe(true);
expect(INFERENCE_OPENAPI_PATHS.length).toBeGreaterThan(0);
expect(typeof createClient).toBe("function");
});

it("type aliases compile (ClientOptions, StreamCallbacks)", () => {
const opts: ClientOptions = { apiKey: "k", baseURL: BASE };
const cbs: StreamCallbacks = { onData: () => {}, onComplete: () => {} };
expect(opts.apiKey).toBe("k");
expect(typeof cbs.onData).toBe("function");
});

it("namespace-style usage routes to the same inference endpoint", async () => {
const c = new inference.Client({ apiKey: "test-key" });

nock(BASE)
.post("/v1/chat/completions")
.reply(200, {
object: "chat.completion",
choices: [{ message: { role: "assistant", content: "ok" } }],
});

const completion = await c.chat.completions.create({
model: "m",
messages: [{ role: "user", content: "hi" }],
});

expect(completion!.choices?.[0]?.message?.content).toBe("ok");
});

it("named-import Client works for chat.completions.create", async () => {
const c = new Client({ apiKey: "test-key" });

nock(BASE)
.post("/v1/chat/completions")
.reply(200, {
object: "chat.completion",
choices: [{ message: { role: "assistant", content: "hello" } }],
});

const completion = await c.chat.completions.create({
model: "m",
messages: [{ role: "user", content: "hi" }],
});

expect(completion!.choices?.[0]?.message?.content).toBe("hello");
});
});
Loading