@clocknext/sdk 0.8.0 → 0.10.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
@@ -41,7 +41,16 @@ await cnk.flush();
41
41
  ## Recording signals
42
42
 
43
43
  There are three signal types, matching ClockNext's billable primitives. Each has
44
- a typed helper that enforces the right required fields at compile time.
44
+ a typed helper that enforces the right required fields at compile time, and each
45
+ posts to **its own route** — `/api/v1/signal/credit`, `/signal/wallet`,
46
+ `/signal/outcome`. The signal's `type` is what picks that route; from 0.10 it is
47
+ no longer sent in the body.
48
+
49
+ > **Migrating from 0.9 or earlier?** Your calling code does not change — same
50
+ > methods, same arguments. What changed is the wire: the SDK now targets the
51
+ > `/api/v1/signal/*` family instead of the deprecated `POST /api/v1/usage`, and
52
+ > the `metric` field is now `composite` (see [Composites](#composites)). Bump the
53
+ > package and you are migrated.
45
54
 
46
55
  `model` is always the **catalog model id** enabled in your org (matched
47
56
  case-insensitively against `OrgModel.modelId`). Credits and outcomes are
@@ -76,23 +85,54 @@ await cnk.signals.outcome({ customerId, model, agentKey: "resolved_ticket.verify
76
85
  await cnk.signals.outcome({ customerId, model, agentKey: "resolved_ticket.resolve", runId, complete: true, tokens: { input: 500, output: 200 } });
77
86
  ```
78
87
 
79
- ### Pricing metrics
88
+ ### Composites
80
89
 
81
- Tag any credit or outcome signal with a **pricing metric** for reporting. `ref`
82
- is the metric's ref ID (Settings → Pricing metrics) and `value` is your own
83
- correlation id; the metric rolls usage up one row per distinct value.
90
+ A **composite** bundles several credits / outcomes / units and is priced and
91
+ billed as one thing. Tag a signal with `composite` to group it into one
92
+ *occurrence* — `ref` is the composite's ref ID and `value` is your own
93
+ correlation id for that occurrence (a session, a call, a job):
84
94
 
85
95
  ```ts
86
96
  await cnk.signals.credit({
87
97
  customerId, model: "llama-3.3-70b-versatile", agentKey: "api_credit",
88
98
  tokens: { input: 1200, output: 340 },
89
- metric: { ref: "voice_ai", value: "call_5f3a" },
99
+ composite: { ref: "voice_ai", value: "call_5f3a" },
100
+ });
101
+ ```
102
+
103
+ An occurrence opens on the first tagged signal and costs nothing. **Closing it
104
+ is what bills it** — one charge for the bundle, however many signals it
105
+ contained:
106
+
107
+ ```ts
108
+ await cnk.signals.completeComposite({
109
+ customerId,
110
+ composite: { ref: "voice_ai", value: "call_5f3a" },
90
111
  });
91
112
  ```
92
113
 
93
- Metric tags are **reporting only** — they never change what's billed, a tag that
94
- names no live metric is silently ignored, and a bad tag never fails the signal.
95
- Tags on wallet signals are ignored.
114
+ Only your code knows when the work ended, so closing is a declaration, not
115
+ something ClockNext infers from which signals have arrived: tagged signals may
116
+ arrive in any order and the occurrence stays open and unbilled until you say
117
+ otherwise. Put the call on the line before your own `return`.
118
+
119
+ An **outcome never needs it** — its own `complete: true` closes its run and the
120
+ matching occurrence together. Credits and units have no "done" signal of their
121
+ own, so this is theirs.
122
+
123
+ Closing twice is safe (`ALREADY_COMPLETED`, and it cannot bill again), and so is
124
+ closing *early* — before every entitlement in the bundle has reported, or while
125
+ a prepaid slot pool is full: the server parks the request and applies it when
126
+ the blocker clears, rather than failing it.
127
+
128
+ A tag that names no live composite is ignored and never fails the signal — the
129
+ signal carries money, the tag only decides what it is grouped under. Tags on
130
+ wallet signals are ignored.
131
+
132
+ > **Renamed in 0.10.** This field was called `metric` and was documented as
133
+ > "reporting only, never changes what's billed". That was wrong: where the plan
134
+ > sells the composite, the occurrence is the billable unit. Rename `metric:` to
135
+ > `composite:` — the shape (`{ ref, value }`) is unchanged.
96
136
 
97
137
  ### Async vs sync
98
138
 
@@ -102,9 +142,9 @@ buffer on a size/interval trigger. This keeps billing telemetry off your LLM hot
102
142
  path.
103
143
 
104
144
  > **"Batching" here means timing, not a bulk request.** Each signal is still its
105
- > own `POST /api/v1/usage`; the flusher just groups *when* sends happen and caps
106
- > how many run at once (`batch.maxConcurrency`). There is no single bulk-ingest
107
- > call, so size your request budget per signal.
145
+ > own POST to its own `/api/v1/signal/*` route; the flusher just groups *when*
146
+ > sends happen and caps how many run at once (`batch.maxConcurrency`). There is
147
+ > no single bulk-ingest call, so size your request budget per signal.
108
148
 
109
149
  To await the request itself (rather than buffering it), either:
110
150
 
@@ -160,7 +200,7 @@ const cnk = new ClockNext({ apiKey, mode: "sync" });
160
200
  const c = await cnk.customers.create({ name: "Acme", email: "ops@acme.com" });
161
201
  const one = await cnk.customers.get(c.id);
162
202
  const page = await cnk.customers.list({ limit: 50, q: "ac" });
163
- await cnk.customers.update(c.id, { notes: "VIP" });
203
+ await cnk.customers.update(c.id, { legalName: "Acme Inc." });
164
204
  await cnk.customers.delete(c.id);
165
205
 
166
206
  // iterate every customer (auto-follows the cursor)
@@ -176,7 +216,7 @@ await cnk.customers.revenue(c.id, { range: "30d" });
176
216
  ### Members
177
217
 
178
218
  ```ts
179
- await cnk.customers.addMember(c.id, { name: "Alice", role: "ADMIN", email: "alice@acme.com" });
219
+ await cnk.customers.addMember(c.id, { name: "Alice", email: "alice@acme.com" });
180
220
  const members = await cnk.customers.listMembers(c.id);
181
221
  await cnk.customers.updateMember(c.id, members[0].id, { name: "Alice R." });
182
222
  await cnk.customers.removeMember(c.id, members[0].id);
@@ -256,13 +296,35 @@ await cnk.units.create({ name: "Seats", agentKey: "seats", pricingType: "FLAT",
256
296
 
257
297
  ### Unit metering
258
298
 
259
- Record a unit event (no tokens) and read a customer's unit balances:
299
+ Record a unit event (no tokens, no model) and read a customer's unit balances:
260
300
 
261
301
  ```ts
262
302
  await cnk.signals.unit({ customerId: c.id, agentKey: "seats" });
303
+
304
+ // several at once, instead of N separate calls
305
+ await cnk.signals.unit({ customerId: c.id, agentKey: "seats", quantity: 3 });
306
+
307
+ // part of a composite, and safe to retry
308
+ await cnk.signals.unit({
309
+ customerId: c.id, agentKey: "seats",
310
+ composite: { ref: "voice_ai", value: "call_5f3a" },
311
+ idempotencyKey: `seats:${requestId}`,
312
+ });
313
+
263
314
  await cnk.signals.unitUsage({ customerId: c.id });
264
315
  ```
265
316
 
317
+ `quantity` defaults to 1. On a prepaid (ADVANCE) plan the draw is
318
+ all-or-nothing: asking for more than remains is refused in full rather than
319
+ partially filled.
320
+
321
+ > **Changed in 0.10.** `signals.unit()` moved to `POST /api/v1/signal/unit`,
322
+ > which is **durable** (the report is written down before it is priced, so a
323
+ > failure parks for retry instead of losing a billable event) and **idempotent**
324
+ > (on the old route a retried report drew the balance down twice). Because it can
325
+ > now answer `202`, this resolves `{ queued, unitUsage, rawId }` rather than a
326
+ > bare `{ unitUsage }` — branch on `queued`, don't assume `unitUsage`.
327
+
266
328
  ## Webhooks
267
329
 
268
330
  Threshold alerts — at most one per kind (`CREDIT` / `OUTCOME` / `WALLET`):
package/dist/index.cjs CHANGED
@@ -121,7 +121,8 @@ var Flusher = class {
121
121
  try {
122
122
  await this.transport.request({
123
123
  method: "POST",
124
- path: "/api/v1/usage",
124
+ // Per-item, not per-Flusher: one route per meter. See `QueuedSignal`.
125
+ path: item.path,
125
126
  body: item.body,
126
127
  // Best-effort metering opts into retries on transient failures.
127
128
  retry: true
@@ -229,7 +230,7 @@ function sleep(ms) {
229
230
  }
230
231
 
231
232
  // src/http.ts
232
- var SDK_VERSION = "0.8.0" ;
233
+ var SDK_VERSION = "0.10.0" ;
233
234
  var Transport = class {
234
235
  constructor(cfg) {
235
236
  this.cfg = cfg;
@@ -324,6 +325,36 @@ var Transport = class {
324
325
  }
325
326
  };
326
327
 
328
+ // src/resources/composites.ts
329
+ var Composites = class {
330
+ constructor(transport) {
331
+ this.transport = transport;
332
+ }
333
+ transport;
334
+ /** List composites. Pass `{ active: true }` for only the ones a signal can
335
+ * still be tagged with — the default list INCLUDES archived ones, which
336
+ * keep every row they already own. */
337
+ async list(params = {}) {
338
+ const res = await this.transport.request({
339
+ method: "GET",
340
+ path: "/api/v1/composites",
341
+ query: {
342
+ active: params.active === void 0 ? void 0 : String(params.active)
343
+ }
344
+ });
345
+ return res.composites;
346
+ }
347
+ /** Create a composite. `POST /api/v1/composites`. */
348
+ async create(input) {
349
+ const res = await this.transport.request({
350
+ method: "POST",
351
+ path: "/api/v1/composites",
352
+ body: input
353
+ });
354
+ return res.composite;
355
+ }
356
+ };
357
+
327
358
  // src/resources/credits.ts
328
359
  var Credits = class {
329
360
  constructor(transport) {
@@ -506,7 +537,7 @@ var Customers = class {
506
537
  });
507
538
  return res.members;
508
539
  }
509
- /** Add a member to a customer. Duplicate `employeeId` → ConflictError. */
540
+ /** Add a member to a customer. Duplicate `email` → ConflictError. */
510
541
  async addMember(id, input) {
511
542
  const res = await this.transport.request({
512
543
  method: "POST",
@@ -515,7 +546,7 @@ var Customers = class {
515
546
  });
516
547
  return res.member;
517
548
  }
518
- /** Update a member's profile (role is fixed). `PATCH …/:id/members/:memberId`. */
549
+ /** Update a member's profile. Duplicate `email` → ConflictError. `PATCH …/:id/members/:memberId`. */
519
550
  async updateMember(id, memberId, input) {
520
551
  const res = await this.transport.request({
521
552
  method: "PATCH",
@@ -524,12 +555,13 @@ var Customers = class {
524
555
  });
525
556
  return res.member;
526
557
  }
527
- /** Remove a member from a customer. `DELETE …/:id/members/:memberId`. */
558
+ /** Remove a member from a customer, returning who was removed. `DELETE …/:id/members/:memberId`. */
528
559
  async removeMember(id, memberId) {
529
- await this.transport.request({
560
+ const res = await this.transport.request({
530
561
  method: "DELETE",
531
562
  path: `/api/v1/customers/${encodeURIComponent(id)}/members/${encodeURIComponent(memberId)}`
532
563
  });
564
+ return res.member;
533
565
  }
534
566
  // --- Wallet & billing config --------------------------------------------
535
567
  /** The customer's wallet balance + transactions. `GET …/:id/wallet`. */
@@ -855,18 +887,28 @@ function newKey() {
855
887
  }
856
888
 
857
889
  // src/resources/signals.ts
890
+ var SIGNAL_PATHS = {
891
+ credit: "/api/v1/signal/credit",
892
+ outcome: "/api/v1/signal/outcome",
893
+ wallet: "/api/v1/signal/wallet"
894
+ };
895
+ var UNIT_PATH = "/api/v1/signal/unit";
896
+ var COMPOSITE_COMPLETE_PATH = "/api/v1/signal/composite/complete";
858
897
  function signalToWire(signal) {
898
+ const t = signal.tokens;
859
899
  const body = {
860
900
  customerId: signal.customerId,
861
- type: signal.type,
862
- model: signal.model
901
+ // `usage` is required on all three model-priced kinds. input/output are
902
+ // always sent; `cacheTokens` only when provided (the server defaults 0).
903
+ usage: {
904
+ model: signal.model,
905
+ inputTokens: t.input,
906
+ outputTokens: t.output,
907
+ ...t.cache != null ? { cacheTokens: t.cache } : {}
908
+ }
863
909
  };
864
910
  if (signal.member) body.member = signal.member;
865
911
  if (signal.custom) body.custom = signal.custom;
866
- const t = signal.tokens;
867
- body.inputTokens = t.input;
868
- body.outputTokens = t.output;
869
- if (t.cache != null) body.cacheTokens = t.cache;
870
912
  if (signal.type === "credit" || signal.type === "outcome") {
871
913
  body.agentKey = signal.agentKey;
872
914
  }
@@ -874,12 +916,18 @@ function signalToWire(signal) {
874
916
  body.runId = signal.runId;
875
917
  if (signal.complete != null) body.complete = signal.complete;
876
918
  }
877
- if (signal.type !== "wallet" && signal.metric) {
878
- body[signal.metric.ref] = signal.metric.value;
919
+ if (signal.type !== "wallet" && signal.composite) {
920
+ body.composite = { [signal.composite.ref]: signal.composite.value };
879
921
  }
880
922
  if (signal.idempotencyKey) body.idempotencyKey = signal.idempotencyKey;
881
923
  return body;
882
924
  }
925
+ function handles(res) {
926
+ return {
927
+ ...res.messageId != null ? { messageId: res.messageId } : {},
928
+ ...res.rawId != null ? { rawId: res.rawId } : {}
929
+ };
930
+ }
883
931
  var Signals = class {
884
932
  constructor(transport, flusher, cfg) {
885
933
  this.transport = transport;
@@ -890,7 +938,8 @@ var Signals = class {
890
938
  flusher;
891
939
  cfg;
892
940
  /**
893
- * Record one usage signal. `POST /api/v1/usage`.
941
+ * Record one usage signal, to the route its `type` selects:
942
+ * `POST /api/v1/signal/credit` | `/signal/outcome` | `/signal/wallet`.
894
943
  *
895
944
  * Returns as soon as the signal is buffered (async mode) or the request has
896
945
  * been answered (`{ wait: true }` / sync mode). See {@link TrackResult}: a
@@ -899,14 +948,15 @@ var Signals = class {
899
948
  async track(signal, opts = {}) {
900
949
  const body = signalToWire(signal);
901
950
  if (body.idempotencyKey == null) body.idempotencyKey = newKey();
951
+ const path = SIGNAL_PATHS[signal.type];
902
952
  const sendNow = opts.wait || this.cfg.mode === "sync";
903
953
  if (!sendNow) {
904
- this.flusher.enqueue({ body, signal });
954
+ this.flusher.enqueue({ path, body, signal });
905
955
  return { queued: true };
906
956
  }
907
957
  const res = await this.transport.request({
908
958
  method: "POST",
909
- path: "/api/v1/usage",
959
+ path,
910
960
  body,
911
961
  // Best-effort metering opts into retries on transient failures.
912
962
  retry: true
@@ -914,39 +964,52 @@ var Signals = class {
914
964
  return {
915
965
  queued: res.queued === true,
916
966
  usageLog: res.usageLog ?? null,
917
- ...res.messageId != null ? { messageId: res.messageId } : {}
967
+ ...handles(res)
918
968
  };
919
969
  }
920
970
  /**
921
971
  * Validate + price a signal WITHOUT recording it (server `dryRun`). Returns
922
972
  * the projected usage log so an integration can preflight before sending real
923
973
  * traffic — no inbox row, wallet / outcome writes, or webhooks fire.
974
+ *
975
+ * Only the model-priced kinds support this. A dry run is not offered for unit
976
+ * reports or composite completions, and the server REJECTS the flag on those
977
+ * routes rather than accepting and ignoring it — which is why there is no
978
+ * dry-run path through `unit()` or `completeComposite()`.
924
979
  */
925
980
  async verify(signal) {
926
981
  const body = signalToWire(signal);
927
982
  body.dryRun = true;
928
983
  const res = await this.transport.request({
929
984
  method: "POST",
930
- path: "/api/v1/usage",
985
+ path: SIGNAL_PATHS[signal.type],
931
986
  body,
932
987
  // A dry run writes nothing, so it is always safe to retry.
933
988
  retry: true
934
989
  });
935
990
  return res.usageLog ?? null;
936
991
  }
937
- /** Meter a named credit. */
992
+ /** Meter a named credit. `POST /api/v1/signal/credit`. */
938
993
  credit(input, opts) {
939
994
  return this.track({ ...input, type: "credit" }, opts);
940
995
  }
941
- /** Debit the customer's wallet at model cost. */
996
+ /** Debit the customer's wallet at model cost. `POST /api/v1/signal/wallet`. */
942
997
  wallet(input, opts) {
943
998
  return this.track({ ...input, type: "wallet" }, opts);
944
999
  }
945
- /** Advance one step of an outcome workflow. */
1000
+ /** Advance one step of an outcome workflow. `POST /api/v1/signal/outcome`. */
946
1001
  outcome(input, opts) {
947
1002
  return this.track({ ...input, type: "outcome" }, opts);
948
1003
  }
949
- /** Recent usage logs + totals for a customer. `GET /api/v1/usage`. */
1004
+ /**
1005
+ * Recent usage logs + totals for a customer. `GET /api/v1/usage`.
1006
+ *
1007
+ * Still the legacy path, deliberately. The `/api/v1/signal/*` family is
1008
+ * POST-only — it replaced how usage is INGESTED, not how it is read — so
1009
+ * `GET /api/v1/usage` has no successor and is not deprecated. It is also the
1010
+ * only way to prove a signal landed, which matters more now that ingestion
1011
+ * answers `202` for everything.
1012
+ */
950
1013
  async list(params) {
951
1014
  const res = await this.transport.request({
952
1015
  method: "GET",
@@ -958,22 +1021,94 @@ var Signals = class {
958
1021
  /**
959
1022
  * Record one unit-metered event (a fixed-price meter with no tokens),
960
1023
  * addressed to a catalog unit by its `agentKey`. Sent immediately — unit
961
- * events are not buffered. `POST /api/v1/units`.
1024
+ * events are not buffered. `POST /api/v1/signal/unit`.
1025
+ *
1026
+ * Two things changed with the move off `POST /api/v1/units`, and both show up
1027
+ * in the return type. The route is now durable (the report lands in an inbox
1028
+ * before it is priced, so a failure parks as Pending and can be retried
1029
+ * instead of losing a billable event) and it answers `202` when it queues —
1030
+ * so this resolves {@link UnitTrackResult}, not a bare `{ unitUsage }`.
1031
+ * Branch on `queued`.
962
1032
  */
963
1033
  async unit(input) {
964
- return this.transport.request({
1034
+ const res = await this.transport.request({
965
1035
  method: "POST",
966
- path: "/api/v1/units",
1036
+ path: UNIT_PATH,
967
1037
  body: {
968
1038
  customerId: input.customerId,
969
1039
  agentKey: input.agentKey,
970
- ...input.member ? { member: input.member } : {}
1040
+ ...input.quantity != null ? { quantity: input.quantity } : {},
1041
+ ...input.member ? { member: input.member } : {},
1042
+ ...input.composite ? { composite: { [input.composite.ref]: input.composite.value } } : {},
1043
+ // Assigned HERE, before the send, so the SAME key rides every
1044
+ // transport-level retry below. Without it a single 500 followed by a
1045
+ // success is TWO un-keyed reports, and the server dedups on the key —
1046
+ // so it bills the allowance down twice, up to `retry.maxAttempts`
1047
+ // times. The route gained idempotency precisely to stop that; not
1048
+ // using it by default would leave the double-charge reachable from the
1049
+ // client instead of the server.
1050
+ idempotencyKey: input.idempotencyKey ?? newKey()
971
1051
  },
972
1052
  // Best-effort metering opts into retries on transient failures.
973
1053
  retry: true
974
1054
  });
1055
+ return {
1056
+ queued: res.queued === true,
1057
+ unitUsage: res.unitUsage ?? null,
1058
+ ...handles(res)
1059
+ };
975
1060
  }
976
- /** A customer's unit balances + recent unit events. `GET /api/v1/units`. */
1061
+ /**
1062
+ * Close ONE occurrence of a composite — a distinct tag value.
1063
+ * `POST /api/v1/signal/composite/complete`.
1064
+ *
1065
+ * A composite bundles several credits / outcomes / units and is billed as one
1066
+ * thing. The occurrence opens on the first tagged signal and costs nothing;
1067
+ * this call is the moment it is billed. Only your code knows when the
1068
+ * workflow ended, so this is a DECLARATION, not a hint: tagged signals may
1069
+ * arrive in any order and the occurrence stays open and unbilled until you
1070
+ * say otherwise. Put it on the line before your own `return`.
1071
+ *
1072
+ * An OUTCOME never needs this — its own `complete: true` closes its run and
1073
+ * the matching occurrence together. Credits and units have no "done" signal
1074
+ * of their own, so this is theirs.
1075
+ *
1076
+ * Closing twice is safe: the second call answers `ALREADY_COMPLETED` and
1077
+ * cannot bill again. Calling it EARLY is also safe — before every entitlement
1078
+ * the composite is restricted to has reported, or while an ADVANCE pool is
1079
+ * full — the server parks the ping and applies it when the blocker clears,
1080
+ * rather than failing it.
1081
+ */
1082
+ async completeComposite(input) {
1083
+ const res = await this.transport.request({
1084
+ method: "POST",
1085
+ path: COMPOSITE_COMPLETE_PATH,
1086
+ body: {
1087
+ customerId: input.customerId,
1088
+ composite: { [input.composite.ref]: input.composite.value },
1089
+ // Auto-assigned like the others. A retry here could not double-BILL
1090
+ // even without it — closing a closed occurrence is a server-side
1091
+ // no-op — but that safety comes from occurrence-level dedup, not from
1092
+ // idempotency, and an un-keyed replay still writes a redundant inbox
1093
+ // row. Keying it collapses the retry into the one attempt it is.
1094
+ idempotencyKey: input.idempotencyKey ?? newKey()
1095
+ },
1096
+ retry: true
1097
+ });
1098
+ return {
1099
+ queued: res.queued === true,
1100
+ ...res.status != null ? { status: res.status } : {},
1101
+ ...res.composite !== void 0 ? { composite: res.composite } : {},
1102
+ ...res.value !== void 0 ? { value: res.value } : {},
1103
+ ...handles(res)
1104
+ };
1105
+ }
1106
+ /**
1107
+ * A customer's unit balances + recent unit events. `GET /api/v1/units`.
1108
+ *
1109
+ * Legacy path for the same reason as {@link list}: the signal family is
1110
+ * POST-only, so the read side has no successor and is not deprecated.
1111
+ */
977
1112
  unitUsage(params) {
978
1113
  return this.transport.request({
979
1114
  method: "GET",
@@ -1117,6 +1252,8 @@ var ClockNext = class {
1117
1252
  payments;
1118
1253
  /** Manage the credit-type catalog. */
1119
1254
  credits;
1255
+ /** Manage the composite catalog — bundles billed as one thing. */
1256
+ composites;
1120
1257
  /** Manage the outcome-type catalog. */
1121
1258
  outcomes;
1122
1259
  /** Manage the unit-type catalog. */
@@ -1145,6 +1282,7 @@ var ClockNext = class {
1145
1282
  this.invoices = new Invoices(transport);
1146
1283
  this.payments = new Payments(transport);
1147
1284
  this.credits = new Credits(transport);
1285
+ this.composites = new Composites(transport);
1148
1286
  this.outcomes = new Outcomes(transport);
1149
1287
  this.units = new Units(transport);
1150
1288
  this.webhooks = new Webhooks(transport);