@clocknext/sdk 0.11.0 → 0.12.1
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 +549 -264
- package/dist/index.cjs +257 -116
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +228 -43
- package/dist/index.d.ts +228 -43
- package/dist/index.js +257 -117
- package/dist/index.js.map +1 -1
- package/examples/node-basic.ts +93 -0
- package/examples/serverless-flush.ts +47 -0
- package/package.json +3 -1
- package/src/client.ts +107 -0
- package/src/config.ts +116 -0
- package/src/errors.ts +215 -0
- package/src/filters.ts +33 -0
- package/src/flusher.ts +144 -0
- package/src/http.ts +178 -0
- package/src/idempotency.ts +33 -0
- package/src/index.ts +165 -0
- package/src/queue.ts +48 -0
- package/src/resources/composites.ts +47 -0
- package/src/resources/credits.ts +76 -0
- package/src/resources/customers.ts +298 -0
- package/src/resources/invoices.ts +51 -0
- package/src/resources/outcomes.ts +82 -0
- package/src/resources/payments.ts +30 -0
- package/src/resources/plans.ts +76 -0
- package/src/resources/portal.ts +31 -0
- package/src/resources/purchases.ts +75 -0
- package/src/resources/signals.ts +393 -0
- package/src/resources/units.ts +76 -0
- package/src/resources/webhooks.ts +37 -0
- package/src/resources/workspace.ts +33 -0
- package/src/retry.ts +56 -0
- package/src/types.ts +1601 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import type { Transport } from "../http.js";
|
|
2
|
+
import type { Flusher } from "../flusher.js";
|
|
3
|
+
import { newKey } from "../idempotency.js";
|
|
4
|
+
import type {
|
|
5
|
+
CompositeCompletionInput,
|
|
6
|
+
CompositeCompletionResult,
|
|
7
|
+
CreditInput,
|
|
8
|
+
OutcomeInput,
|
|
9
|
+
ResolvedConfig,
|
|
10
|
+
SerializedUnitUsage,
|
|
11
|
+
Signal,
|
|
12
|
+
SignalType,
|
|
13
|
+
TrackResult,
|
|
14
|
+
UnitInput,
|
|
15
|
+
UnitTrackResult,
|
|
16
|
+
UnitUsageList,
|
|
17
|
+
UsageList,
|
|
18
|
+
WalletInput,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Which route each signal type posts to.
|
|
23
|
+
*
|
|
24
|
+
* This map IS the migration off `POST /api/v1/usage`. The signal's `type` no
|
|
25
|
+
* longer travels in the body — it picks the URL, and the server reads the
|
|
26
|
+
* meter off the path. That is the server's design, not a convenience: a body
|
|
27
|
+
* can neither omit the meter nor contradict it, so the whole class of "the
|
|
28
|
+
* `type` field said credit but the fields describe a unit" bug is gone.
|
|
29
|
+
*
|
|
30
|
+
* `type` stays on the SDK's own `Signal` objects because it is still the
|
|
31
|
+
* discriminator callers write and TypeScript narrows on. It just stops being
|
|
32
|
+
* something we send.
|
|
33
|
+
*/
|
|
34
|
+
const SIGNAL_PATHS: Record<SignalType, string> = {
|
|
35
|
+
credit: "/api/v1/signal/credit",
|
|
36
|
+
outcome: "/api/v1/signal/outcome",
|
|
37
|
+
wallet: "/api/v1/signal/wallet",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Unit reports and composite completions are not part of the `Signal` union
|
|
41
|
+
* (no model, no tokens), so they carry their paths separately. */
|
|
42
|
+
const UNIT_PATH = "/api/v1/signal/unit";
|
|
43
|
+
const COMPOSITE_COMPLETE_PATH = "/api/v1/signal/composite/complete";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds the request body for a typed Signal.
|
|
47
|
+
*
|
|
48
|
+
* Two grouping changes from the pre-0.10 `POST /api/v1/usage` body, both of
|
|
49
|
+
* which the server now requires:
|
|
50
|
+
*
|
|
51
|
+
* usage: { model, inputTokens, outputTokens, cacheTokens }
|
|
52
|
+
* composite: { "<refId>": "<value>" }
|
|
53
|
+
*
|
|
54
|
+
* `usage` collects the four fields describing one LLM call. `composite` is the
|
|
55
|
+
* more consequential one: the tag used to sit at the TOP LEVEL under its own
|
|
56
|
+
* caller-defined property name, which meant a composite called `model` or
|
|
57
|
+
* `status` shadowed a real ingest field. Nested, the two namespaces are
|
|
58
|
+
* separate and that collision is impossible.
|
|
59
|
+
*
|
|
60
|
+
* The route is NOT part of this body — see {@link SIGNAL_PATHS}.
|
|
61
|
+
*/
|
|
62
|
+
export function signalToWire(signal: Signal): Record<string, unknown> {
|
|
63
|
+
const t = signal.tokens;
|
|
64
|
+
const body: Record<string, unknown> = {
|
|
65
|
+
customerId: signal.customerId,
|
|
66
|
+
// `usage` is required on all three model-priced kinds. input/output are
|
|
67
|
+
// always sent; `cacheTokens` only when provided (the server defaults 0).
|
|
68
|
+
usage: {
|
|
69
|
+
model: signal.model,
|
|
70
|
+
inputTokens: t.input,
|
|
71
|
+
outputTokens: t.output,
|
|
72
|
+
...(t.cache != null ? { cacheTokens: t.cache } : {}),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
if (signal.member) body.member = signal.member;
|
|
76
|
+
if (signal.custom) body.custom = signal.custom;
|
|
77
|
+
|
|
78
|
+
// credit + outcome both resolve their target via the stable `agentKey`.
|
|
79
|
+
// A wallet signal has none: it meters money at the model's cost rather than
|
|
80
|
+
// a named entitlement.
|
|
81
|
+
if (signal.type === "credit" || signal.type === "outcome") {
|
|
82
|
+
body.agentKey = signal.agentKey;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Outcome runs are grouped by `runId`; `complete` ends the run.
|
|
86
|
+
if (signal.type === "outcome") {
|
|
87
|
+
body.runId = signal.runId;
|
|
88
|
+
if (signal.complete != null) body.complete = signal.complete;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Composite tag, nested under its `refId`. Never set for wallet — the server
|
|
92
|
+
// accepts and ignores one there, but sending it would only imply a rollup
|
|
93
|
+
// that will not exist.
|
|
94
|
+
if (signal.type !== "wallet" && signal.composite) {
|
|
95
|
+
body.composite = { [signal.composite.ref]: signal.composite.value };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A caller-supplied idempotency key is forwarded verbatim; `track()` fills in
|
|
99
|
+
// an auto-generated one when none was given.
|
|
100
|
+
if (signal.idempotencyKey) body.idempotencyKey = signal.idempotencyKey;
|
|
101
|
+
return body;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The fields every `/api/v1/signal/*` response may carry. */
|
|
105
|
+
interface SignalResponse {
|
|
106
|
+
queued?: boolean;
|
|
107
|
+
messageId?: string;
|
|
108
|
+
rawId?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Copy the two durability handles onto a result, each only when present. */
|
|
112
|
+
function handles(res: SignalResponse) {
|
|
113
|
+
return {
|
|
114
|
+
...(res.messageId != null ? { messageId: res.messageId } : {}),
|
|
115
|
+
...(res.rawId != null ? { rawId: res.rawId } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The `signals` resource: record usage signals and read them back.
|
|
121
|
+
*
|
|
122
|
+
* In async mode (default), `track()` buffers the signal locally and returns
|
|
123
|
+
* immediately with `{ status: "buffered" }`. Pass `{ wait: true }` (or
|
|
124
|
+
* construct the client in `sync` mode) to await the request instead.
|
|
125
|
+
*
|
|
126
|
+
* Awaiting the request does NOT mean waiting for a price, and deliberately so.
|
|
127
|
+
* The server writes every signal to a durable inbox and hands it to a worker,
|
|
128
|
+
* answering `202` — so `track()` resolves `{ status: "accepted", messageId,
|
|
129
|
+
* rawId, usageLog: null }`: impossible to lose, not yet billed. Only when the
|
|
130
|
+
* server prices inline do you get `{ status: "recorded", usageLog }`. Branch on
|
|
131
|
+
* `status`; never assume `usageLog`.
|
|
132
|
+
*
|
|
133
|
+
* Nothing here ever blocks a request thread on billing. That is the point: a
|
|
134
|
+
* meter that makes the caller wait for pricing has moved your billing system
|
|
135
|
+
* onto your product's critical path.
|
|
136
|
+
*
|
|
137
|
+
* Because of that, a signal the server later REJECTS (unknown `agentKey`, a
|
|
138
|
+
* model the org has not enabled, no active plan) cannot come back through this
|
|
139
|
+
* promise — and does not reach `onError` either, since from here that request
|
|
140
|
+
* succeeded. Such a rejection has no programmatic channel at all: it is
|
|
141
|
+
* recorded on the workspace's signal log in the ClockNext dashboard, which is
|
|
142
|
+
* owner-only by design.
|
|
143
|
+
*
|
|
144
|
+
* `list()` is the partial check available from code. It returns priced usage
|
|
145
|
+
* logs with a `status`, so it confirms a signal landed and whether pricing
|
|
146
|
+
* succeeded — but a signal refused BEFORE pricing never becomes a usage log
|
|
147
|
+
* and does not appear there.
|
|
148
|
+
*/
|
|
149
|
+
export class Signals {
|
|
150
|
+
constructor(
|
|
151
|
+
private readonly transport: Transport,
|
|
152
|
+
private readonly flusher: Flusher,
|
|
153
|
+
private readonly cfg: ResolvedConfig,
|
|
154
|
+
) {}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Record one usage signal, to the route its `type` selects:
|
|
158
|
+
* `POST /api/v1/signal/credit` | `/signal/outcome` | `/signal/wallet`.
|
|
159
|
+
*
|
|
160
|
+
* Returns as soon as the signal is buffered (async mode) or the request has
|
|
161
|
+
* been answered (`{ wait: true }` / sync mode). See {@link TrackResult}: a
|
|
162
|
+
* resolved promise means "accepted", which is not the same as "billed".
|
|
163
|
+
*/
|
|
164
|
+
async track(
|
|
165
|
+
signal: Signal,
|
|
166
|
+
opts: { wait?: boolean } = {},
|
|
167
|
+
): Promise<TrackResult> {
|
|
168
|
+
const body = signalToWire(signal);
|
|
169
|
+
// Assign the idempotency key ONCE, here — before the signal is buffered or
|
|
170
|
+
// sent — so the SAME key rides every retry (Transport-level replay AND a
|
|
171
|
+
// re-flush of the buffered body). That is what makes a retried signal
|
|
172
|
+
// dedup server-side instead of double-recording (see idempotency.ts, AD-4).
|
|
173
|
+
const idempotencyKey =
|
|
174
|
+
typeof body.idempotencyKey === "string" ? body.idempotencyKey : newKey();
|
|
175
|
+
body.idempotencyKey = idempotencyKey;
|
|
176
|
+
|
|
177
|
+
// The key is stamped back onto the SIGNAL, not just the body, because the
|
|
178
|
+
// signal is what `onError`/`onDrop` receive. Without this, a caller writing
|
|
179
|
+
// the documented dead-letter path — catch the drop, persist it, resend
|
|
180
|
+
// later — would resend with a freshly generated key, and the server would
|
|
181
|
+
// treat it as a second event and bill it twice. The one mechanism that
|
|
182
|
+
// prevents duplicates has to be reachable from the one hook that handles
|
|
183
|
+
// failures.
|
|
184
|
+
const tracked: Signal = { ...signal, idempotencyKey };
|
|
185
|
+
|
|
186
|
+
const path = SIGNAL_PATHS[signal.type];
|
|
187
|
+
|
|
188
|
+
const sendNow = opts.wait || this.cfg.mode === "sync";
|
|
189
|
+
if (!sendNow) {
|
|
190
|
+
// The path is buffered WITH the body. The flusher used to hardcode one
|
|
191
|
+
// endpoint for everything; now that each meter has its own, a buffered
|
|
192
|
+
// signal has to remember where it was going or async mode would quietly
|
|
193
|
+
// keep posting to a route sync mode has already left behind.
|
|
194
|
+
// A closed client or a full queue discards the signal. Saying
|
|
195
|
+
// "buffered" there would hand back a success-shaped result for a
|
|
196
|
+
// billable event that no longer exists.
|
|
197
|
+
const buffered = this.flusher.enqueue({ path, body, signal: tracked });
|
|
198
|
+
return buffered
|
|
199
|
+
? { status: "buffered", queued: true }
|
|
200
|
+
: { status: "dropped", queued: false };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const res = await this.transport.request<
|
|
204
|
+
SignalResponse & { usageLog?: TrackResult["usageLog"] }
|
|
205
|
+
>({
|
|
206
|
+
method: "POST",
|
|
207
|
+
path,
|
|
208
|
+
body,
|
|
209
|
+
// Best-effort metering opts into retries on transient failures.
|
|
210
|
+
retry: true,
|
|
211
|
+
idempotencyKey,
|
|
212
|
+
});
|
|
213
|
+
// The server answers one of two ways for a real record, and we must not
|
|
214
|
+
// flatten them: `202 { queued: true, messageId, rawId }` when it hands the
|
|
215
|
+
// signal to its durable queue (priced later by a worker — no log yet), or
|
|
216
|
+
// `200 { usageLog, rawId }` when it prices inline. Either way `rawId` is
|
|
217
|
+
// the handle on a written, unlosable row.
|
|
218
|
+
const queued = res.queued === true;
|
|
219
|
+
return {
|
|
220
|
+
status: queued ? "accepted" : "recorded",
|
|
221
|
+
queued,
|
|
222
|
+
usageLog: res.usageLog ?? null,
|
|
223
|
+
...handles(res),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Meter a named credit. `POST /api/v1/signal/credit`. */
|
|
228
|
+
credit(input: CreditInput, opts?: { wait?: boolean }): Promise<TrackResult> {
|
|
229
|
+
return this.track({ ...input, type: "credit" }, opts);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Debit the customer's wallet at model cost. `POST /api/v1/signal/wallet`. */
|
|
233
|
+
wallet(input: WalletInput, opts?: { wait?: boolean }): Promise<TrackResult> {
|
|
234
|
+
return this.track({ ...input, type: "wallet" }, opts);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Advance one step of an outcome workflow. `POST /api/v1/signal/outcome`. */
|
|
238
|
+
outcome(
|
|
239
|
+
input: OutcomeInput,
|
|
240
|
+
opts?: { wait?: boolean },
|
|
241
|
+
): Promise<TrackResult> {
|
|
242
|
+
return this.track({ ...input, type: "outcome" }, opts);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Recent usage logs + totals for a customer. `GET /api/v1/usage`.
|
|
247
|
+
*
|
|
248
|
+
* Still the legacy path, deliberately. The `/api/v1/signal/*` family is
|
|
249
|
+
* POST-only — it replaced how usage is INGESTED, not how it is read — so
|
|
250
|
+
* `GET /api/v1/usage` has no successor and is not deprecated. It is also the
|
|
251
|
+
* only way to prove a signal landed, which matters more now that ingestion
|
|
252
|
+
* answers `202` for everything.
|
|
253
|
+
*/
|
|
254
|
+
async list(params: {
|
|
255
|
+
customerId: string;
|
|
256
|
+
limit?: number;
|
|
257
|
+
}): Promise<UsageList> {
|
|
258
|
+
const res = await this.transport.request<UsageList>({
|
|
259
|
+
method: "GET",
|
|
260
|
+
path: "/api/v1/usage",
|
|
261
|
+
query: { customerId: params.customerId, limit: params.limit },
|
|
262
|
+
});
|
|
263
|
+
return { customer: res.customer, totals: res.totals, logs: res.logs };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Record one unit-metered event (a fixed-price meter with no tokens),
|
|
268
|
+
* addressed to a catalog unit by its `agentKey`. Sent immediately — unit
|
|
269
|
+
* events are not buffered. `POST /api/v1/signal/unit`.
|
|
270
|
+
*
|
|
271
|
+
* Two things changed with the move off `POST /api/v1/units`, and both show up
|
|
272
|
+
* in the return type. The route is now durable (the report lands in an inbox
|
|
273
|
+
* before it is priced, so a failure parks as Pending and can be retried
|
|
274
|
+
* instead of losing a billable event) and it answers `202` when it queues —
|
|
275
|
+
* so this resolves {@link UnitTrackResult}, not a bare `{ unitUsage }`.
|
|
276
|
+
* Branch on `queued`.
|
|
277
|
+
*/
|
|
278
|
+
async unit(input: UnitInput): Promise<UnitTrackResult> {
|
|
279
|
+
const idempotencyKey = input.idempotencyKey ?? newKey();
|
|
280
|
+
const res = await this.transport.request<
|
|
281
|
+
SignalResponse & { unitUsage?: SerializedUnitUsage }
|
|
282
|
+
>({
|
|
283
|
+
method: "POST",
|
|
284
|
+
path: UNIT_PATH,
|
|
285
|
+
idempotencyKey,
|
|
286
|
+
body: {
|
|
287
|
+
customerId: input.customerId,
|
|
288
|
+
agentKey: input.agentKey,
|
|
289
|
+
...(input.quantity != null ? { quantity: input.quantity } : {}),
|
|
290
|
+
...(input.member ? { member: input.member } : {}),
|
|
291
|
+
...(input.composite
|
|
292
|
+
? { composite: { [input.composite.ref]: input.composite.value } }
|
|
293
|
+
: {}),
|
|
294
|
+
// Assigned HERE, before the send, so the SAME key rides every
|
|
295
|
+
// transport-level retry below. Without it a single 500 followed by a
|
|
296
|
+
// success is TWO un-keyed reports, and the server dedups on the key —
|
|
297
|
+
// so it bills the allowance down twice, up to `retry.maxAttempts`
|
|
298
|
+
// times. The route gained idempotency precisely to stop that; not
|
|
299
|
+
// using it by default would leave the double-charge reachable from the
|
|
300
|
+
// client instead of the server.
|
|
301
|
+
idempotencyKey,
|
|
302
|
+
},
|
|
303
|
+
// Best-effort metering opts into retries on transient failures.
|
|
304
|
+
retry: true,
|
|
305
|
+
});
|
|
306
|
+
const queued = res.queued === true;
|
|
307
|
+
return {
|
|
308
|
+
status: queued ? "accepted" : "recorded",
|
|
309
|
+
queued,
|
|
310
|
+
unitUsage: res.unitUsage ?? null,
|
|
311
|
+
...handles(res),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Close ONE occurrence of a composite — a distinct tag value.
|
|
317
|
+
* `POST /api/v1/signal/composite/complete`.
|
|
318
|
+
*
|
|
319
|
+
* A composite bundles several credits / outcomes / units and is billed as one
|
|
320
|
+
* thing. The occurrence opens on the first tagged signal and costs nothing;
|
|
321
|
+
* this call is the moment it is billed. Only your code knows when the
|
|
322
|
+
* workflow ended, so this is a DECLARATION, not a hint: tagged signals may
|
|
323
|
+
* arrive in any order and the occurrence stays open and unbilled until you
|
|
324
|
+
* say otherwise. Put it on the line before your own `return`.
|
|
325
|
+
*
|
|
326
|
+
* EVERY composite needs this, outcome-backed ones included. An outcome's own
|
|
327
|
+
* `complete: true` closes its RUN and nothing else — it does not close the
|
|
328
|
+
* composite occurrence the run was tagged into, whatever mix of credits,
|
|
329
|
+
* outcomes and units that composite wraps. (Earlier versions of this SDK
|
|
330
|
+
* documented the opposite. Believing it means the occurrence stays ONGOING
|
|
331
|
+
* forever and is never billed, so if you tag outcome signals with a
|
|
332
|
+
* `composite`, you must still call this.)
|
|
333
|
+
*
|
|
334
|
+
* Closing twice is safe: the second call answers `ALREADY_COMPLETED` and
|
|
335
|
+
* cannot bill again. Calling it EARLY is also safe — before every entitlement
|
|
336
|
+
* the composite is restricted to has reported, or while an ADVANCE pool is
|
|
337
|
+
* full — the server parks the ping and applies it when the blocker clears,
|
|
338
|
+
* rather than failing it.
|
|
339
|
+
*/
|
|
340
|
+
async completeComposite(
|
|
341
|
+
input: CompositeCompletionInput,
|
|
342
|
+
): Promise<CompositeCompletionResult> {
|
|
343
|
+
const idempotencyKey = input.idempotencyKey ?? newKey();
|
|
344
|
+
const res = await this.transport.request<
|
|
345
|
+
SignalResponse & {
|
|
346
|
+
status?: "COMPLETED" | "ALREADY_COMPLETED";
|
|
347
|
+
composite?: CompositeCompletionResult["composite"];
|
|
348
|
+
value?: string | null;
|
|
349
|
+
}
|
|
350
|
+
>({
|
|
351
|
+
method: "POST",
|
|
352
|
+
path: COMPOSITE_COMPLETE_PATH,
|
|
353
|
+
idempotencyKey,
|
|
354
|
+
body: {
|
|
355
|
+
customerId: input.customerId,
|
|
356
|
+
composite: { [input.composite.ref]: input.composite.value },
|
|
357
|
+
// Auto-assigned like the others. A retry here could not double-BILL
|
|
358
|
+
// even without it — closing a closed occurrence is a server-side
|
|
359
|
+
// no-op — but that safety comes from occurrence-level dedup, not from
|
|
360
|
+
// idempotency, and an un-keyed replay still writes a redundant inbox
|
|
361
|
+
// row. Keying it collapses the retry into the one attempt it is.
|
|
362
|
+
idempotencyKey,
|
|
363
|
+
},
|
|
364
|
+
retry: true,
|
|
365
|
+
});
|
|
366
|
+
const queued = res.queued === true;
|
|
367
|
+
return {
|
|
368
|
+
delivery: queued ? "accepted" : "recorded",
|
|
369
|
+
queued,
|
|
370
|
+
...(res.status != null ? { status: res.status } : {}),
|
|
371
|
+
...(res.composite !== undefined ? { composite: res.composite } : {}),
|
|
372
|
+
...(res.value !== undefined ? { value: res.value } : {}),
|
|
373
|
+
...handles(res),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* A customer's unit balances + recent unit events. `GET /api/v1/units`.
|
|
379
|
+
*
|
|
380
|
+
* Legacy path for the same reason as {@link list}: the signal family is
|
|
381
|
+
* POST-only, so the read side has no successor and is not deprecated.
|
|
382
|
+
*/
|
|
383
|
+
unitUsage(params: {
|
|
384
|
+
customerId: string;
|
|
385
|
+
limit?: number;
|
|
386
|
+
}): Promise<UnitUsageList> {
|
|
387
|
+
return this.transport.request<UnitUsageList>({
|
|
388
|
+
method: "GET",
|
|
389
|
+
path: "/api/v1/units",
|
|
390
|
+
query: { customerId: params.customerId, limit: params.limit },
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { activeQuery, filterByActive } from "../filters.js";
|
|
2
|
+
import type { Transport } from "../http.js";
|
|
3
|
+
import type {
|
|
4
|
+
CreateUnitInput,
|
|
5
|
+
Unit,
|
|
6
|
+
UnitDetail,
|
|
7
|
+
UpdateUnitInput,
|
|
8
|
+
} from "../types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The `units` resource — manage the org's unit *types* (the catalog): named,
|
|
12
|
+
* quantity-priced meters (FLAT / SLAB / VOLUME). Record consumption against a
|
|
13
|
+
* unit with `signals.unit()`. `/api/v1/units-catalog`.
|
|
14
|
+
*/
|
|
15
|
+
export class Units {
|
|
16
|
+
constructor(private readonly transport: Transport) {}
|
|
17
|
+
|
|
18
|
+
/** List unit types. Pass `{ active: true }` to only return active ones.
|
|
19
|
+
* `{ active: false }` returns only archived ones; omitting it returns
|
|
20
|
+
* both. See `activeQuery` — the API itself only supports `true`. */
|
|
21
|
+
async list(params: { active?: boolean } = {}): Promise<Unit[]> {
|
|
22
|
+
const res = await this.transport.request<{ units: Unit[] }>({
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/api/v1/units-catalog",
|
|
25
|
+
query: { active: activeQuery(params.active) },
|
|
26
|
+
});
|
|
27
|
+
return filterByActive(res.units, params.active);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Create a unit type. `POST /api/v1/units-catalog`. */
|
|
31
|
+
async create(input: CreateUnitInput): Promise<Unit> {
|
|
32
|
+
const res = await this.transport.request<{ unit: Unit }>({
|
|
33
|
+
method: "POST",
|
|
34
|
+
path: "/api/v1/units-catalog",
|
|
35
|
+
body: input,
|
|
36
|
+
});
|
|
37
|
+
return res.unit;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fetch one unit type with stats. `GET /api/v1/units-catalog/:id`. */
|
|
41
|
+
async get(id: string): Promise<UnitDetail> {
|
|
42
|
+
const res = await this.transport.request<{ unit: UnitDetail }>({
|
|
43
|
+
method: "GET",
|
|
44
|
+
path: `/api/v1/units-catalog/${encodeURIComponent(id)}`,
|
|
45
|
+
});
|
|
46
|
+
return res.unit;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Update a unit type's definition. `PATCH /api/v1/units-catalog/:id`. */
|
|
50
|
+
async update(id: string, input: UpdateUnitInput): Promise<Unit> {
|
|
51
|
+
const res = await this.transport.request<{ unit: Unit }>({
|
|
52
|
+
method: "PATCH",
|
|
53
|
+
path: `/api/v1/units-catalog/${encodeURIComponent(id)}`,
|
|
54
|
+
body: input,
|
|
55
|
+
});
|
|
56
|
+
return res.unit;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Activate or deactivate a unit type. */
|
|
60
|
+
async setActive(id: string, isActive: boolean): Promise<Unit> {
|
|
61
|
+
const res = await this.transport.request<{ unit: Unit }>({
|
|
62
|
+
method: "PATCH",
|
|
63
|
+
path: `/api/v1/units-catalog/${encodeURIComponent(id)}`,
|
|
64
|
+
body: { isActive },
|
|
65
|
+
});
|
|
66
|
+
return res.unit;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Delete a unit type. */
|
|
70
|
+
async delete(id: string): Promise<void> {
|
|
71
|
+
await this.transport.request({
|
|
72
|
+
method: "DELETE",
|
|
73
|
+
path: `/api/v1/units-catalog/${encodeURIComponent(id)}`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Transport } from "../http.js";
|
|
2
|
+
import type { ThresholdKind, UpsertWebhookInput, Webhook } from "../types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `webhooks` resource — threshold alerts. At most one webhook per kind
|
|
6
|
+
* (CREDIT / OUTCOME / WALLET); posting a kind again upserts it. `/api/v1/webhooks`.
|
|
7
|
+
*/
|
|
8
|
+
export class Webhooks {
|
|
9
|
+
constructor(private readonly transport: Transport) {}
|
|
10
|
+
|
|
11
|
+
/** List configured webhooks. `GET /api/v1/webhooks`. */
|
|
12
|
+
async list(): Promise<Webhook[]> {
|
|
13
|
+
const res = await this.transport.request<{ webhooks: Webhook[] }>({
|
|
14
|
+
method: "GET",
|
|
15
|
+
path: "/api/v1/webhooks",
|
|
16
|
+
});
|
|
17
|
+
return res.webhooks;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Create or replace the webhook for a kind. `POST /api/v1/webhooks`. */
|
|
21
|
+
async upsert(input: UpsertWebhookInput): Promise<Webhook> {
|
|
22
|
+
const res = await this.transport.request<{ webhook: Webhook }>({
|
|
23
|
+
method: "POST",
|
|
24
|
+
path: "/api/v1/webhooks",
|
|
25
|
+
body: input,
|
|
26
|
+
});
|
|
27
|
+
return res.webhook;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Delete the webhook for a kind (idempotent). `DELETE /api/v1/webhooks/:kind`. */
|
|
31
|
+
async delete(kind: ThresholdKind): Promise<void> {
|
|
32
|
+
await this.transport.request({
|
|
33
|
+
method: "DELETE",
|
|
34
|
+
path: `/api/v1/webhooks/${encodeURIComponent(kind)}`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { activeQuery, filterByActive } from "../filters.js";
|
|
2
|
+
import type { Transport } from "../http.js";
|
|
3
|
+
import type { Model, WorkspaceIdentity } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `workspace` resource — read-only introspection of the org behind the API
|
|
7
|
+
* key. Grounds an integration (which workspace, and is it `sandbox` or `live`?)
|
|
8
|
+
* and lists the models it may meter against. `/api/v1/me`, `/api/v1/models`.
|
|
9
|
+
*/
|
|
10
|
+
export class Workspace {
|
|
11
|
+
constructor(private readonly transport: Transport) {}
|
|
12
|
+
|
|
13
|
+
/** Identify the org + key behind the request, incl. `sandbox` | `live`. */
|
|
14
|
+
me(): Promise<WorkspaceIdentity> {
|
|
15
|
+
return this.transport.request<WorkspaceIdentity>({
|
|
16
|
+
method: "GET",
|
|
17
|
+
path: "/api/v1/me",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The org's enabled models with live prices. `{ active: true }` returns only
|
|
22
|
+
* the models that can be metered right now.
|
|
23
|
+
* `{ active: false }` returns only archived ones; omitting it returns
|
|
24
|
+
* both. See `activeQuery` — the API itself only supports `true`. */
|
|
25
|
+
async models(params: { active?: boolean } = {}): Promise<Model[]> {
|
|
26
|
+
const res = await this.transport.request<{ models: Model[] }>({
|
|
27
|
+
method: "GET",
|
|
28
|
+
path: "/api/v1/models",
|
|
29
|
+
query: { active: activeQuery(params.active) },
|
|
30
|
+
});
|
|
31
|
+
return filterByActive(res.models, params.active);
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ClockNextError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Retry classification + backoff. Mirrors the convergent industry policy
|
|
5
|
+
* (Stripe / Stainless clients): retry transient transport failures
|
|
6
|
+
* (network, timeout, 408/409/429/5xx); never retry deterministic 4xx
|
|
7
|
+
* (400/401/404 and 422 validation/plan/allowance). Exponential backoff with
|
|
8
|
+
* equal jitter, honoring a server `Retry-After`. The Transport additionally
|
|
9
|
+
* gates retries by request safety (GET/DELETE, or a write that explicitly
|
|
10
|
+
* opts in).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Whether an error should be retried. NetworkError/Server/RateLimit/Conflict
|
|
14
|
+
* carry `retryable: true`; everything else is permanent. */
|
|
15
|
+
export function isRetryable(error: unknown): boolean {
|
|
16
|
+
return error instanceof ClockNextError && error.retryable;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parses `Retry-After` (seconds or HTTP-date) and `retry-after-ms` headers. */
|
|
20
|
+
export function parseRetryAfter(headers: Headers): number | undefined {
|
|
21
|
+
const ms = headers.get("retry-after-ms");
|
|
22
|
+
if (ms) {
|
|
23
|
+
const n = Number(ms);
|
|
24
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
25
|
+
}
|
|
26
|
+
const ra = headers.get("retry-after");
|
|
27
|
+
if (ra) {
|
|
28
|
+
const secs = Number(ra);
|
|
29
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
|
30
|
+
const date = Date.parse(ra);
|
|
31
|
+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Backoff for a given attempt (1-based). Exponential `base * 2^(attempt-1)`,
|
|
38
|
+
* capped at `maxDelayMs`, with equal jitter (×[0.5, 1.0]). A server-provided
|
|
39
|
+
* `retryAfterMs` overrides the computed value.
|
|
40
|
+
*/
|
|
41
|
+
export function computeBackoff(
|
|
42
|
+
attempt: number,
|
|
43
|
+
opts: { baseDelayMs: number; maxDelayMs: number },
|
|
44
|
+
retryAfterMs?: number,
|
|
45
|
+
rand: () => number = Math.random,
|
|
46
|
+
): number {
|
|
47
|
+
if (retryAfterMs != null) return Math.min(retryAfterMs, opts.maxDelayMs);
|
|
48
|
+
const raw = opts.baseDelayMs * Math.pow(2, Math.max(0, attempt - 1));
|
|
49
|
+
const capped = Math.min(raw, opts.maxDelayMs);
|
|
50
|
+
// Equal jitter: a random point in [0.5, 1.0] of the capped delay.
|
|
51
|
+
return Math.round(capped * (0.5 + rand() * 0.5));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function sleep(ms: number): Promise<void> {
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
}
|