@sandstormapp/sdk 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,170 +1,137 @@
1
- # @sandstormapp/sdk
1
+ # ads.dev
2
2
 
3
- Sandstorm's public API as a typed client for Node and edge runtimes: one method per tool in the catalogue, typed from the tool's own JSON Schema, and the tasks those calls start. It is generated from the API's OpenAPI document and depends on nothing at runtime but `fetch`.
3
+ The [ads.dev](https://ads.dev) API for Node, Deno, Bun and edge runtimes: image, video and audio generation with 34 models on your brand, the brand kit and library, and the ads your competitors run. Every model's input is typed, so the model you name decides what `input` takes.
4
+
5
+ It is published as `ads.dev`, `adsdotdev`, `sandstormapp` and `@sandstormapp/sdk`, the same package under four names, and needs nothing at runtime but `fetch` and Web Crypto.
4
6
 
5
7
  ```bash
6
- yarn add @sandstormapp/sdk
8
+ npm install ads.dev
7
9
  ```
8
10
 
9
11
  ```ts
10
- import { Sandstorm } from '@sandstormapp/sdk'
12
+ import { Sandstorm } from 'ads.dev'
11
13
 
12
- const sandstorm = new Sandstorm({ apiKey: process.env.SANDSTORM_API_KEY })
14
+ const sandstorm = new Sandstorm()
13
15
 
14
- const task = await sandstorm.tools.generate_image_nano_banana_pro(
15
- { prompt: 'A ceramic mug on a sunlit bench, steam rising, morning light', asset_name: 'Sunlit Mug' },
16
- { wait: 120 },
17
- )
18
- if (task.status === 'succeeded') console.log(task.files)
19
- ```
20
-
21
- ```bash
22
- curl -X POST https://agent.sandstormapp.com/v1/tools/generate_image_nano_banana_pro \
23
- -H "Authorization: Bearer $SANDSTORM_API_KEY" \
24
- -H 'Content-Type: application/json' \
25
- -H 'Prefer: wait=120' \
26
- -d '{"prompt":"A ceramic mug on a sunlit bench, steam rising, morning light","asset_name":"Sunlit Mug"}'
16
+ const generation = await sandstorm.generations.run('nano-banana-pro', {
17
+ prompt: 'A ceramic mug on a sunlit bench, steam rising',
18
+ aspect_ratio: '4:5',
19
+ })
20
+ console.log(generation.output[0]?.url)
27
21
  ```
28
22
 
29
23
  ## A key
30
24
 
31
- Settings → API keys mints a key, `sk_live_…`, shown once. A key works on one brand of your organization: the brand it was made on, or the one you name.
25
+ `new Sandstorm()` reads `SANDSTORM_API_KEY`. Make a key in Sandstorm under Settings → API keys, or in one command, which signs you in in the browser and writes the key to `.env`:
26
+
27
+ ```bash
28
+ curl -fsSL https://ads.dev/login.mjs -o sandstorm-login.mjs && node sandstorm-login.mjs
29
+ ```
32
30
 
33
31
  ```ts
34
- new Sandstorm({ apiKey, brand: '2f7fa2e7-c931-47b2-a66a-f1393233f2b3' })
32
+ new Sandstorm({
33
+ apiKey: process.env.SANDSTORM_API_KEY,
34
+ brand: '6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f',
35
+ baseUrl: 'https://ads.dev',
36
+ maxRetries: 2,
37
+ timeoutMs: 60_000,
38
+ })
35
39
  ```
36
40
 
37
- `brand` is sent as `x-sandstorm-brand` on every request. `baseUrl` points the client somewhere other than `https://agent.sandstormapp.com`, such as an API running locally.
41
+ A key acts on the brand it was made on; `brand` names another brand of the same organization. `baseUrl` defaults to `SANDSTORM_API_URL`, then `https://ads.dev`. Keep the key on your server: anyone who holds it spends your credits.
38
42
 
39
- ## Calling a tool
43
+ ## Generations
40
44
 
41
- Every tool is a method on `sandstorm.tools`, named as the catalogue names it, taking the body the API holds it to. A call starts a task and answers its id at once:
45
+ `run` makes a generation and answers it once it has finished, holding each request on the API with `Prefer: wait` rather than polling. A generation that fails or is canceled throws `GenerationFailed`, which carries it.
42
46
 
43
47
  ```ts
44
- const started = await sandstorm.tools.search_assets({ types: ['image'], limit: 10 })
45
- // { task_id: '…', status: 'running' }
46
- ```
48
+ const still = await sandstorm.generations.run('nano-banana-2-lite', { prompt: 'Two lemons in a blue bowl' })
47
49
 
48
- ```bash
49
- curl -X POST https://agent.sandstormapp.com/v1/tools/search_assets \
50
- -H "Authorization: Bearer $SANDSTORM_API_KEY" -H 'Content-Type: application/json' \
51
- -d '{"types":["image"],"limit":10}'
50
+ const clip = await sandstorm.generations.run('seedance-2.5/image-to-video', {
51
+ start_image: still.output[0]!.id,
52
+ prompt: 'Slow push in as the light warms',
53
+ resolution: '720p',
54
+ })
52
55
  ```
53
56
 
54
- With `wait`, the API holds the request up to that many seconds (at most 200) and answers the finished task, or the started shape when the time runs out:
55
-
56
- ```ts
57
- const answer = await sandstorm.tools.search_assets({ types: ['image'], limit: 10 }, { wait: 30 })
58
- if (answer.status === 'running') console.log('still running', answer.task_id)
59
- else console.log(answer.result)
60
- ```
57
+ Every media input takes an asset id (`asset_…`) or a public https URL, so the output of one generation goes straight into the next.
61
58
 
62
- A tool that asks the person before it runs in the app takes `confirm: true` here, since nobody is there to ask; its input type requires it, and the API refuses the call with 403 without it:
59
+ For work that runs long, queue it and come back to it, or take a webhook:
63
60
 
64
61
  ```ts
65
- await sandstorm.tools.revoke_asset_share({ slug: 'summer-drop', confirm: true })
66
- ```
62
+ const queued = await sandstorm.generations.create('kling-3-pro/image-to-video', input, { webhookUrl: 'https://yourapp.com/hooks/sandstorm' })
63
+ const finished = await sandstorm.generations.wait(queued.id)
67
64
 
68
- ```bash
69
- curl -X POST https://agent.sandstormapp.com/v1/tools/revoke_asset_share \
70
- -H "Authorization: Bearer $SANDSTORM_API_KEY" -H 'Content-Type: application/json' \
71
- -d '{"slug":"summer-drop","confirm":true}'
65
+ await sandstorm.generations.get(queued.id)
66
+ await sandstorm.generations.cancel(queued.id)
72
67
  ```
73
68
 
74
- The catalogue itself, every tool this key can call with its schema, hints and whether it asks first:
69
+ A generation is `queued`, then `running`, then `succeeded`, `failed` or `canceled`; `credits` is what it was charged once it finished, and a failed one is free. Cancelling a queued generation costs nothing; one the model has already started stops where the model allows it, and otherwise finishes and is charged.
70
+
71
+ `create` sends an `Idempotency-Key` with every call and keeps it across its retries, so a retry never makes a second generation. Pass your own to make a call safe across process restarts:
75
72
 
76
73
  ```ts
77
- const { tools } = await sandstorm.catalogue()
74
+ await sandstorm.generations.create('nano-banana-pro', input, { idempotencyKey: 'campaign-42-hero' })
78
75
  ```
79
76
 
80
- ```bash
81
- curl https://agent.sandstormapp.com/v1/tools -H "Authorization: Bearer $SANDSTORM_API_KEY"
82
- ```
77
+ ## Lists
83
78
 
84
- ## Following a task
79
+ Every list answers one page when awaited and walks every page when iterated:
85
80
 
86
81
  ```ts
87
- const task = await sandstorm.tasks.get(taskId)
88
- ```
82
+ const page = await sandstorm.generations.list({ status: 'succeeded', limit: 50 })
89
83
 
90
- ```bash
91
- curl https://agent.sandstormapp.com/v1/tasks/$TASK_ID -H "Authorization: Bearer $SANDSTORM_API_KEY"
84
+ for await (const asset of sandstorm.assets.list({ type: 'image' })) console.log(asset.id, asset.url)
92
85
  ```
93
86
 
94
- A task is `{ task_id, tool, status, error, started_at, finished_at, result?, files? }`, where `status` is `running`, `succeeded`, `failed` or `cancelled`, `result` is the tool's own JSON once it succeeded and `files` are the files it made or showed, each with a `ref` other tools take and a `url` that keeps working. A task the API has only just started answers `{ task_id, status: 'running' }` until it is on record.
95
-
96
- `tasks.wait` polls that route every two seconds until the status leaves `running`, and resolves to the task with its result or error:
87
+ ## Everything else
97
88
 
98
89
  ```ts
99
- const finished = await sandstorm.tasks.wait(taskId)
100
- if (finished.status === 'failed') console.error(finished.error)
101
- ```
90
+ await sandstorm.models.list({ type: 'video' })
91
+ await sandstorm.models.get('nano-banana-pro/edit')
102
92
 
103
- The twenty most recent calls of this key on its brand, newest first, without their results:
93
+ await sandstorm.brand.get()
94
+ await sandstorm.assets.import('https://yourcdn.com/hero.png', { name: 'Hero shot' })
95
+ await sandstorm.assets.get('asset_9c1e77aa')
96
+ await sandstorm.offers.list({ query: 'cold brew' })
97
+ await sandstorm.offers.get('offer_0f8e2b6a4c1d4e7f9a3b5c7d9e1f2a4b')
98
+ await sandstorm.characters.list({ kind: 'subject' })
99
+ await sandstorm.voices.list({ use_case: 'advertisement', limit: 5 })
104
100
 
105
- ```ts
106
- const { tasks } = await sandstorm.tasks.list()
101
+ const [advertiser] = (await sandstorm.advertisers.list({ query: 'Liquid Death' })).data
102
+ await sandstorm.ads.list({ advertiser: advertiser!.id, status: 'active' })
103
+ await sandstorm.ads.get('ad_5e1c9a3b')
107
104
  ```
108
105
 
109
- ```bash
110
- curl https://agent.sandstormapp.com/v1/tasks -H "Authorization: Bearer $SANDSTORM_API_KEY"
111
- ```
106
+ ## Webhooks
112
107
 
113
- Stopping one that still runs; whatever it saved before it stopped stays in the library:
108
+ A generation made with `webhookUrl` is POSTed to it once it finishes, signed in `Sandstorm-Signature`. Verify the raw body before you trust it:
114
109
 
115
110
  ```ts
116
- await sandstorm.tasks.stop(taskId)
117
- ```
111
+ import { verifyWebhook } from 'ads.dev'
118
112
 
119
- ```bash
120
- curl -X POST https://agent.sandstormapp.com/v1/tasks/$TASK_ID/stop -H "Authorization: Bearer $SANDSTORM_API_KEY"
113
+ const secret = await sandstorm.webhooks.secret()
114
+
115
+ const event = await verifyWebhook({ secret, body: await request.text(), signature: request.headers.get('sandstorm-signature') })
116
+ if (event.type === 'generation.succeeded') console.log(event.data.output)
121
117
  ```
122
118
 
123
- ## Refusals
119
+ It refuses a body that was changed, a signature from another secret and a delivery older than five minutes. An event's `id` is the same on every retry of it, so a handler can skip one it has seen.
120
+
121
+ ## Errors
124
122
 
125
- A response the API refuses throws a `SandstormError` with the HTTP `status`, the API's `code` and its message:
123
+ A refusal throws `SandstormError` with the HTTP `status`, its `type` and `code`, the `param` at fault and the `requestId` to quote to support. Requests Sandstorm could not take (408, 429, 500, 502, 503, 504) and network failures are retried twice with backoff, after the `Retry-After` the API asks for.
126
124
 
127
125
  ```ts
128
- import { SandstormError } from '@sandstormapp/sdk'
126
+ import { SandstormError } from 'ads.dev'
129
127
 
130
128
  try {
131
- await sandstorm.tools.archive_offer({ offer: 'offer:1a2b3c4d' })
129
+ await sandstorm.generations.create('nano-banana-pro', { prompt: 'A mug on a bench' })
132
130
  } catch (error) {
133
- if (error instanceof SandstormError && error.code === 'forbidden') console.log(error.message)
131
+ if (error instanceof SandstormError && error.code === 'insufficient_credits') console.log('Add credits in Sandstorm')
134
132
  }
135
133
  ```
136
134
 
137
- | status | code | when |
138
- | --- | --- | --- |
139
- | 400 | `bad_request` | the body does not match the tool's schema; the message names the mismatch |
140
- | 401 | `unauthorized` | the bearer is not a live key |
141
- | 402 | `billing` | the organization cannot run Sandstorm right now |
142
- | 403 | `forbidden` | the tool asks first and the body did not carry `confirm: true`, or the brand named is not one of the organization's |
143
- | 404 | `tool_not_found`, `task_not_found` | no such tool for this brand, or no such task |
144
- | 409 | `task_not_running` | the task to stop has already ended |
145
- | 429 | `rate_limited` | more than 300 requests in a minute from this key |
146
-
147
- An answer that is not the API's, such as one from the edge, throws with its `status`, no `code`, and the text of the answer.
148
-
149
- ## The generated client
150
-
151
- `src/openapi.ts` and `openapi.json` are generated from the API's `GET /v1/openapi.json`, which the API produces from its live tool catalogue, and both are committed, so the package builds without an API. To regenerate:
152
-
153
- ```bash
154
- yarn generate https://agent.sandstormapp.com/v1/openapi.json # from a running API, replacing openapi.json
155
- yarn generate path/to/openapi.json # from a document on disk
156
- yarn generate # from the committed openapi.json
157
- ```
158
-
159
- `yarn test` fails when the committed `src/openapi.ts` is not what the committed document generates, and a document regenerated from the API shows every catalogue change as a diff of `openapi.json`. `agent/README.md` says how to run the API locally; its document is at `http://localhost:8787/v1/openapi.json`.
160
-
161
135
  ## Developing
162
136
 
163
- ```bash
164
- yarn install
165
- yarn typecheck # src, test and scripts
166
- yarn test # the client against a stubbed fetch, and the stale-output check
167
- yarn build # dist/, which is what the package ships
168
- ```
169
-
170
- Node 22.18 or newer runs `scripts/generate.ts` as it is.
137
+ `openapi.json` is the API's own document, held to what the API serves by the agent project's tests; `yarn generate` turns it into `src/openapi.ts`, and `yarn ci:test` fails when the two disagree. `yarn release` publishes the build as `ads.dev`, `adsdotdev`, `sandstormapp` and `@sandstormapp/sdk`.
package/dist/index.d.ts CHANGED
@@ -1,47 +1,139 @@
1
- import { toolNames, type components, type paths } from './openapi.ts';
2
- export { toolNames };
3
- export type Task = components['schemas']['Task'];
4
- export type Started = components['schemas']['Started'];
5
- export type Tool = components['schemas']['Tool'];
6
- export type Finished = Task & {
7
- readonly status: Exclude<Task['status'], 'running'>;
1
+ import type { components, operations } from './openapi.ts';
2
+ type Schemas = components['schemas'];
3
+ export type Generation = Schemas['Generation'];
4
+ export type GenerationEvent = Schemas['GenerationEvent'];
5
+ export type Model = Schemas['Model'];
6
+ export type Asset = Schemas['Asset'];
7
+ export type LibraryAsset = Schemas['LibraryAsset'];
8
+ export type Brand = Schemas['Brand'];
9
+ export type OfferSummary = Schemas['OfferSummary'];
10
+ export type Offer = Schemas['Offer'];
11
+ export type Character = Schemas['Character'];
12
+ export type Voice = Schemas['Voice'];
13
+ export type Advertiser = Schemas['Advertiser'];
14
+ export type AdSummary = Schemas['AdSummary'];
15
+ export type Ad = Schemas['Ad'];
16
+ type CreateGeneration = Schemas['CreateGeneration'];
17
+ type Refusal = Schemas['Error']['error'];
18
+ export type ModelId = CreateGeneration['model'];
19
+ export type ModelInput<Id extends ModelId> = Extract<CreateGeneration, {
20
+ model: Id;
21
+ }>['input'];
22
+ type Query<Operation extends keyof operations> = NonNullable<operations[Operation]['parameters']['query']>;
23
+ type Flag<Value> = [Value] extends ['true' | 'false' | undefined] ? boolean : Value;
24
+ type Params<Operation extends keyof operations> = {
25
+ readonly [Name in Exclude<keyof Query<Operation>, 'cursor'>]?: Flag<Query<Operation>[Name]>;
8
26
  };
9
- export type ToolName = (typeof toolNames)[number];
10
- export type ToolInput<Name extends ToolName> = paths[`/v1/tools/${Name}`]['post']['requestBody']['content']['application/json'];
11
- export type Catalogue = paths['/v1/tools']['get']['responses'][200]['content']['application/json'];
12
- export type TaskList = paths['/v1/tasks']['get']['responses'][200]['content']['application/json'];
13
- export type Stopped = paths['/v1/tasks/{taskId}/stop']['post']['responses'][202]['content']['application/json'];
14
- export interface Wait {
15
- readonly wait: number;
16
- }
17
- export interface ToolCall<Name extends ToolName> {
18
- (input: ToolInput<Name>): Promise<Started>;
19
- (input: ToolInput<Name>, options: Wait): Promise<Task | Started>;
20
- }
21
- export type Tools = {
22
- readonly [Name in ToolName]: ToolCall<Name>;
27
+ export type GenerationParams = Params<'listGenerations'>;
28
+ export type ModelParams = Params<'listModels'>;
29
+ export type AssetParams = Params<'listAssets'>;
30
+ export type OfferParams = Params<'listOffers'>;
31
+ export type CharacterParams = Params<'listCharacters'>;
32
+ export type VoiceParams = Params<'listVoices'>;
33
+ export type AdvertiserParams = Params<'listAdvertisers'> & {
34
+ readonly query: string;
23
35
  };
24
- export interface Tasks {
25
- list(): Promise<TaskList>;
26
- get(taskId: string): Promise<Task | Started>;
27
- stop(taskId: string): Promise<Stopped>;
28
- wait(taskId: string): Promise<Finished>;
36
+ export type AdParams = Params<'listAds'>;
37
+ export interface List<Item> {
38
+ readonly object: 'list';
39
+ readonly data: Item[];
40
+ readonly has_more: boolean;
41
+ readonly next_cursor: string | null;
29
42
  }
30
43
  export interface Options {
31
- readonly apiKey: string;
32
- readonly brand?: string;
44
+ readonly apiKey?: string;
33
45
  readonly baseUrl?: string;
46
+ readonly brand?: string;
47
+ readonly maxRetries?: number;
48
+ readonly timeoutMs?: number;
49
+ readonly fetch?: typeof fetch;
50
+ }
51
+ export interface CreateOptions {
52
+ readonly webhookUrl?: string;
53
+ readonly wait?: number;
54
+ readonly idempotencyKey?: string;
55
+ }
56
+ export interface WaitOptions {
57
+ readonly timeoutMs?: number;
34
58
  }
35
59
  export declare class SandstormError extends Error {
36
60
  readonly name = "SandstormError";
37
61
  readonly status: number;
38
- readonly code: string | undefined;
39
- constructor(status: number, code: string | undefined, message: string);
62
+ readonly type: Refusal['type'];
63
+ readonly code: string;
64
+ readonly param: string | undefined;
65
+ readonly requestId: string | undefined;
66
+ constructor(status: number, refusal: Omit<Refusal, 'request_id'> & {
67
+ readonly request_id?: string;
68
+ });
69
+ }
70
+ export declare class GenerationFailed extends Error {
71
+ readonly name = "GenerationFailed";
72
+ readonly generation: Generation;
73
+ constructor(generation: Generation);
74
+ }
75
+ export declare const isFinished: (generation: Generation) => boolean;
76
+ export declare class Page<Item> implements PromiseLike<List<Item>>, AsyncIterable<Item> {
77
+ #private;
78
+ constructor(read: (cursor: string | undefined) => Promise<List<Item>>);
79
+ then<Fulfilled = List<Item>, Rejected = never>(fulfilled?: ((list: List<Item>) => Fulfilled | PromiseLike<Fulfilled>) | null, rejected?: ((reason: unknown) => Rejected | PromiseLike<Rejected>) | null): Promise<Fulfilled | Rejected>;
80
+ [Symbol.asyncIterator](): AsyncIterator<Item>;
40
81
  }
41
82
  export declare class Sandstorm {
42
83
  #private;
43
- readonly tools: Tools;
44
- readonly tasks: Tasks;
45
- constructor({ apiKey, brand, baseUrl }: Options);
46
- catalogue(): Promise<Catalogue>;
84
+ constructor({ apiKey, baseUrl, brand, maxRetries, timeoutMs, fetch: fetcher, }?: Options);
85
+ readonly generations: {
86
+ create: <Id extends ModelId>(model: Id, input: ModelInput<Id>, { webhookUrl, wait, idempotencyKey }?: CreateOptions) => Promise<Generation>;
87
+ get: (id: string, { wait }?: {
88
+ readonly wait?: number;
89
+ }) => Promise<Generation>;
90
+ wait: (id: string, { timeoutMs }?: WaitOptions) => Promise<Generation>;
91
+ run: <Id extends ModelId>(model: Id, input: ModelInput<Id>, { timeoutMs, ...options }?: Omit<CreateOptions, "wait"> & WaitOptions) => Promise<Generation>;
92
+ cancel: (id: string) => Promise<Generation>;
93
+ list: (params?: GenerationParams) => Page<Generation>;
94
+ };
95
+ readonly models: {
96
+ list: (params?: ModelParams) => Page<Model>;
97
+ get: (id: ModelId) => Promise<Model>;
98
+ };
99
+ readonly assets: {
100
+ list: (params?: AssetParams) => Page<LibraryAsset>;
101
+ get: (id: string) => Promise<Asset>;
102
+ import: (url: string, { name, idempotencyKey }?: {
103
+ readonly name?: string;
104
+ readonly idempotencyKey?: string;
105
+ }) => Promise<Asset>;
106
+ };
107
+ readonly brand: {
108
+ get: () => Promise<Brand>;
109
+ };
110
+ readonly offers: {
111
+ list: (params?: OfferParams) => Page<OfferSummary>;
112
+ get: (id: string) => Promise<Offer>;
113
+ };
114
+ readonly characters: {
115
+ list: (params?: CharacterParams) => Page<Character>;
116
+ };
117
+ readonly voices: {
118
+ list: (params?: VoiceParams) => Page<Voice>;
119
+ };
120
+ readonly advertisers: {
121
+ list: (params: AdvertiserParams) => Page<Advertiser>;
122
+ };
123
+ readonly ads: {
124
+ list: (params?: AdParams) => Page<AdSummary>;
125
+ get: (id: string) => Promise<Ad>;
126
+ };
127
+ readonly webhooks: {
128
+ secret: () => Promise<string>;
129
+ };
130
+ }
131
+ export interface Delivery {
132
+ readonly secret: string;
133
+ readonly body: string;
134
+ readonly signature: string | null;
135
+ readonly toleranceSeconds?: number;
136
+ readonly now?: number;
47
137
  }
138
+ export declare function verifyWebhook({ secret, body, signature, toleranceSeconds, now }: Delivery): Promise<GenerationEvent>;
139
+ export {};