@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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic usage: record a few signals from a long-lived Node process.
|
|
3
|
+
* Run with: CLOCKNEXT_API_KEY=cnk_… npx tsx examples/node-basic.ts
|
|
4
|
+
*
|
|
5
|
+
* Note the discovery step first. `customerId`, `model` and `agentKey` are not
|
|
6
|
+
* strings you can invent — they name rows that must already exist in your
|
|
7
|
+
* workspace, and a signal naming one that does not is rejected. So this example
|
|
8
|
+
* reads them out of the API rather than hard-coding placeholders that are
|
|
9
|
+
* guaranteed to 422 on someone else's workspace.
|
|
10
|
+
*/
|
|
11
|
+
import { ClockNext } from "@clocknext/sdk";
|
|
12
|
+
|
|
13
|
+
const cnk = new ClockNext({
|
|
14
|
+
apiKey: process.env.CLOCKNEXT_API_KEY!,
|
|
15
|
+
// baseUrl: "http://localhost:3000", // point at local dev if needed
|
|
16
|
+
onError: (err, signal) =>
|
|
17
|
+
console.error("[clocknext] signal failed:", signal?.type, err),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
// 0. Ground the integration. Check WHICH workspace this key points at before
|
|
22
|
+
// sending anything — a live key bills for real.
|
|
23
|
+
const { organization } = await cnk.workspace.me();
|
|
24
|
+
console.log(`workspace: ${organization.name} (${organization.environment})`);
|
|
25
|
+
|
|
26
|
+
// 1. Discover real ids instead of guessing them.
|
|
27
|
+
const [models, credits, customers] = await Promise.all([
|
|
28
|
+
cnk.workspace.models({ active: true }),
|
|
29
|
+
cnk.credits.list({ active: true }),
|
|
30
|
+
cnk.customers.list({ limit: 1 }),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const model = models[0];
|
|
34
|
+
const credit = credits[0];
|
|
35
|
+
const customer = customers.customers[0];
|
|
36
|
+
if (!model || !credit || !customer) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"This workspace needs at least one enabled model, one credit type, and one customer before it can meter anything.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
console.log(`metering ${credit.agentKey} on ${model.modelId}`);
|
|
42
|
+
|
|
43
|
+
// 2. Credit usage — metered against a credit by its stable `agentKey`, priced
|
|
44
|
+
// by tokens. Optionally group it into a composite occurrence, billed once
|
|
45
|
+
// on close.
|
|
46
|
+
await cnk.signals.credit({
|
|
47
|
+
customerId: customer.id,
|
|
48
|
+
model: model.modelId,
|
|
49
|
+
agentKey: credit.agentKey,
|
|
50
|
+
tokens: { input: 1200, output: 340, cache: 0 },
|
|
51
|
+
custom: { feature: "summarize", tier: "pro" },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// 3. Wallet usage (debits the customer's USD wallet at model cost).
|
|
55
|
+
await cnk.signals.wallet({
|
|
56
|
+
customerId: customer.id,
|
|
57
|
+
model: model.modelId,
|
|
58
|
+
tokens: { input: 800, output: 5000 },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// 4. Outcome run — every step shares a `runId`; mark the last one `complete`
|
|
62
|
+
// to bill the run. `agentKey` is the OutcomeStep's key, which you read off
|
|
63
|
+
// `cnk.outcomes.list()` the same way.
|
|
64
|
+
// `outcomes.list()` returns catalogue rows WITHOUT their steps; the step
|
|
65
|
+
// agent keys — which is what a signal reports against — are on the detail.
|
|
66
|
+
const outcomeRow = (await cnk.outcomes.list({ active: true }))[0];
|
|
67
|
+
const outcome = outcomeRow ? await cnk.outcomes.get(outcomeRow.id) : undefined;
|
|
68
|
+
if (outcome?.steps[0]) {
|
|
69
|
+
const runId = `demo_run_${Date.now()}`;
|
|
70
|
+
await cnk.signals.outcome({
|
|
71
|
+
customerId: customer.id,
|
|
72
|
+
model: model.modelId,
|
|
73
|
+
agentKey: outcome.steps[0].agentKey,
|
|
74
|
+
runId,
|
|
75
|
+
complete: true, // closes the RUN; a composite still needs its own close
|
|
76
|
+
tokens: { input: 600, output: 150 },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 5. Flush the background buffer, then read the signals back. Ingestion is
|
|
81
|
+
// asynchronous, so this — not the track() return value — is what proves a
|
|
82
|
+
// signal actually landed.
|
|
83
|
+
await cnk.flush();
|
|
84
|
+
const usage = await cnk.signals.list({ customerId: customer.id, limit: 10 });
|
|
85
|
+
console.log("recent logs:", usage.logs.length, "totals:", usage.totals);
|
|
86
|
+
|
|
87
|
+
await cnk.close();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
main().catch((err) => {
|
|
91
|
+
console.error(err);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serverless (Vercel / AWS Lambda): the process can freeze the instant the
|
|
3
|
+
* handler returns, so you MUST await a flush before responding — otherwise
|
|
4
|
+
* buffered signals are lost. Either construct the client in `sync` mode, or
|
|
5
|
+
* (better for throughput) buffer and `await cnk.flush()` at the end.
|
|
6
|
+
*/
|
|
7
|
+
import { ClockNext } from "@clocknext/sdk";
|
|
8
|
+
|
|
9
|
+
// Construct ONCE at module scope so the client is reused across warm
|
|
10
|
+
// invocations; a per-request client buffers into an instance that is thrown
|
|
11
|
+
// away before the timer ever fires.
|
|
12
|
+
const cnk = new ClockNext({
|
|
13
|
+
apiKey: process.env.CLOCKNEXT_API_KEY!,
|
|
14
|
+
// In a serverless runtime a dropped signal is invisible unless you log it —
|
|
15
|
+
// there is no console to watch after the function freezes.
|
|
16
|
+
onError: (err, signal) =>
|
|
17
|
+
console.error("[clocknext] signal failed", { type: signal?.type, err }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export async function handler(req: {
|
|
21
|
+
customerId: string;
|
|
22
|
+
model: string;
|
|
23
|
+
agentKey: string;
|
|
24
|
+
requestId: string;
|
|
25
|
+
tokens: { input: number; output: number };
|
|
26
|
+
}) {
|
|
27
|
+
// ... do the LLM call, then record usage without blocking the hot path ...
|
|
28
|
+
await cnk.signals.credit({
|
|
29
|
+
customerId: req.customerId,
|
|
30
|
+
model: req.model,
|
|
31
|
+
agentKey: req.agentKey,
|
|
32
|
+
tokens: req.tokens,
|
|
33
|
+
// A stable key tied to the logical event. The SDK generates one anyway, but
|
|
34
|
+
// an auto key lives in memory — it cannot dedup a retry of the whole
|
|
35
|
+
// invocation, which is exactly what a serverless platform does on failure.
|
|
36
|
+
idempotencyKey: `req:${req.requestId}`,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// CRITICAL in serverless: drain the buffer before the function returns. Put
|
|
40
|
+
// it in a `finally` so an error on the path above still flushes what was
|
|
41
|
+
// already metered.
|
|
42
|
+
try {
|
|
43
|
+
return { ok: true };
|
|
44
|
+
} finally {
|
|
45
|
+
await cnk.flush();
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clocknext/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Official ClockNext SDK — record usage signals and manage plans, purchases, invoices, customers, and billing from your backend.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"examples",
|
|
24
26
|
"README.md"
|
|
25
27
|
],
|
|
26
28
|
"engines": {
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { resolveConfig } from "./config.js";
|
|
2
|
+
import { Flusher } from "./flusher.js";
|
|
3
|
+
import { Transport } from "./http.js";
|
|
4
|
+
import { Composites } from "./resources/composites.js";
|
|
5
|
+
import { Credits } from "./resources/credits.js";
|
|
6
|
+
import { Customers } from "./resources/customers.js";
|
|
7
|
+
import { Invoices } from "./resources/invoices.js";
|
|
8
|
+
import { Outcomes } from "./resources/outcomes.js";
|
|
9
|
+
import { Payments } from "./resources/payments.js";
|
|
10
|
+
import { Plans } from "./resources/plans.js";
|
|
11
|
+
import { Portal } from "./resources/portal.js";
|
|
12
|
+
import { Purchases } from "./resources/purchases.js";
|
|
13
|
+
import { Signals } from "./resources/signals.js";
|
|
14
|
+
import { Units } from "./resources/units.js";
|
|
15
|
+
import { Webhooks } from "./resources/webhooks.js";
|
|
16
|
+
import { Workspace } from "./resources/workspace.js";
|
|
17
|
+
import type { ClockNextConfig, ResolvedConfig } from "./types.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The ClockNext client.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const cnk = new ClockNext({ apiKey: process.env.CLOCKNEXT_API_KEY! });
|
|
24
|
+
*
|
|
25
|
+
* await cnk.signals.credit({
|
|
26
|
+
* customerId: "cus_123",
|
|
27
|
+
* model: "gpt-4o", // catalog model id
|
|
28
|
+
* agentKey: "api_credit", // the credit's stable agent key
|
|
29
|
+
* tokens: { input: 1200, output: 340 },
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* // before a serverless function returns / on shutdown:
|
|
33
|
+
* await cnk.flush();
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export class ClockNext {
|
|
37
|
+
/** Record + read usage signals (credit / wallet / outcome / unit). */
|
|
38
|
+
readonly signals: Signals;
|
|
39
|
+
/** Manage customers, their members, wallet, and balances. */
|
|
40
|
+
readonly customers: Customers;
|
|
41
|
+
/** Define and manage billing plans. */
|
|
42
|
+
readonly plans: Plans;
|
|
43
|
+
/** Subscribe customers to plans (purchases). */
|
|
44
|
+
readonly purchases: Purchases;
|
|
45
|
+
/** Read invoices and mint hosted pay links. */
|
|
46
|
+
readonly invoices: Invoices;
|
|
47
|
+
/** Read payments (paid invoices). */
|
|
48
|
+
readonly payments: Payments;
|
|
49
|
+
/** Manage the credit-type catalog. */
|
|
50
|
+
readonly credits: Credits;
|
|
51
|
+
/** Manage the composite catalog — bundles billed as one thing. */
|
|
52
|
+
readonly composites: Composites;
|
|
53
|
+
/** Manage the outcome-type catalog. */
|
|
54
|
+
readonly outcomes: Outcomes;
|
|
55
|
+
/** Manage the unit-type catalog. */
|
|
56
|
+
readonly units: Units;
|
|
57
|
+
/** Configure threshold webhooks. */
|
|
58
|
+
readonly webhooks: Webhooks;
|
|
59
|
+
/** Mint customer-portal embed tokens. */
|
|
60
|
+
readonly portal: Portal;
|
|
61
|
+
/** Identify the workspace (sandbox/live) + list its models. `/me`, `/models`. */
|
|
62
|
+
readonly workspace: Workspace;
|
|
63
|
+
|
|
64
|
+
private readonly cfg: ResolvedConfig;
|
|
65
|
+
private readonly flusher: Flusher;
|
|
66
|
+
|
|
67
|
+
constructor(config: ClockNextConfig) {
|
|
68
|
+
this.cfg = resolveConfig(config);
|
|
69
|
+
|
|
70
|
+
if (typeof window !== "undefined") {
|
|
71
|
+
this.cfg.logger.warn?.(
|
|
72
|
+
"[clocknext] Running in a browser exposes your secret API key. Use this SDK only on a server/backend.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const transport = new Transport(this.cfg);
|
|
77
|
+
this.flusher = new Flusher(transport, this.cfg);
|
|
78
|
+
this.signals = new Signals(transport, this.flusher, this.cfg);
|
|
79
|
+
this.customers = new Customers(transport);
|
|
80
|
+
this.plans = new Plans(transport);
|
|
81
|
+
this.purchases = new Purchases(transport);
|
|
82
|
+
this.invoices = new Invoices(transport);
|
|
83
|
+
this.payments = new Payments(transport);
|
|
84
|
+
this.credits = new Credits(transport);
|
|
85
|
+
this.composites = new Composites(transport);
|
|
86
|
+
this.outcomes = new Outcomes(transport);
|
|
87
|
+
this.units = new Units(transport);
|
|
88
|
+
this.webhooks = new Webhooks(transport);
|
|
89
|
+
this.portal = new Portal(transport);
|
|
90
|
+
this.workspace = new Workspace(transport);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Number of signals currently buffered (async mode). */
|
|
94
|
+
get pending(): number {
|
|
95
|
+
return this.flusher.size;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Force-send all buffered signals now. Await before a serverless return. */
|
|
99
|
+
flush(): Promise<void> {
|
|
100
|
+
return this.flusher.flush();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Flush and stop the background timer. Call on graceful shutdown. */
|
|
104
|
+
close(): Promise<void> {
|
|
105
|
+
return this.flusher.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ValidationError } from "./errors.js";
|
|
2
|
+
import type {
|
|
3
|
+
ClockNextConfig,
|
|
4
|
+
DropReason,
|
|
5
|
+
FetchLike,
|
|
6
|
+
ResolvedConfig,
|
|
7
|
+
Signal,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BASE_URL = "https://payments.clocknext.com";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default failure reporters.
|
|
14
|
+
*
|
|
15
|
+
* In the default `async` mode a signal is sent by the background flusher, long
|
|
16
|
+
* after `signals.credit()` resolved `{ status: "buffered" }`. There is no
|
|
17
|
+
* promise left to reject into, so a failed REQUEST — a bad API key, an
|
|
18
|
+
* unreachable host, a timeout — has nowhere to surface except these hooks.
|
|
19
|
+
*
|
|
20
|
+
* They do not cover a signal the server accepted (`202`) and then refused while
|
|
21
|
+
* pricing it: that request succeeded as far as this SDK can tell. Those
|
|
22
|
+
* rejections are recorded on the workspace's signal log in the ClockNext
|
|
23
|
+
* dashboard, which is owner-only by design.
|
|
24
|
+
*
|
|
25
|
+
* They used to default to nothing at all, which meant a misconfigured
|
|
26
|
+
* integration compiled, ran, returned success-shaped results, and metered
|
|
27
|
+
* NOTHING, with not one line of output to say so. A billing client whose
|
|
28
|
+
* default is silent data loss has its defaults inverted, so failures are now
|
|
29
|
+
* reported on `console.error` unless the caller takes ownership by supplying
|
|
30
|
+
* their own hook.
|
|
31
|
+
*
|
|
32
|
+
* Pass `onError: () => {}` to silence them deliberately.
|
|
33
|
+
*/
|
|
34
|
+
function defaultOnError(error: unknown, signal?: Signal): void {
|
|
35
|
+
const what = signal ? `${signal.type} signal` : "request";
|
|
36
|
+
const who = signal ? ` customer=${signal.customerId}` : "";
|
|
37
|
+
const key =
|
|
38
|
+
signal?.idempotencyKey != null
|
|
39
|
+
? ` idempotencyKey=${signal.idempotencyKey}`
|
|
40
|
+
: "";
|
|
41
|
+
console.error(
|
|
42
|
+
`[clocknext] ${what} permanently failed and was NOT recorded.${who}${key}`,
|
|
43
|
+
error,
|
|
44
|
+
);
|
|
45
|
+
console.error(
|
|
46
|
+
"[clocknext] Pass `onError` to handle this yourself, or `onError: () => {}` to silence it.",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Drops that never reach `onError` — nothing was sent, so there is no send
|
|
51
|
+
* failure to report. `send_failed` is skipped here because the error hook has
|
|
52
|
+
* already reported it. */
|
|
53
|
+
function defaultOnDrop(signal: Signal, reason: DropReason): void {
|
|
54
|
+
if (reason === "send_failed") return;
|
|
55
|
+
const why =
|
|
56
|
+
reason === "queue_full"
|
|
57
|
+
? "the local queue is full — raise `batch.maxQueueSize` or flush more often"
|
|
58
|
+
: "the client is closed — this signal was tracked after `close()`";
|
|
59
|
+
console.error(
|
|
60
|
+
`[clocknext] dropped a ${signal.type} signal without sending it: ${why}.`,
|
|
61
|
+
`customer=${signal.customerId}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Use `value` only if it's a finite number `>= min`; otherwise fall back. */
|
|
66
|
+
function num(value: number | undefined, fallback: number, min: number): number {
|
|
67
|
+
return typeof value === "number" && Number.isFinite(value) && value >= min
|
|
68
|
+
? value
|
|
69
|
+
: fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Applies defaults and validates required config. */
|
|
73
|
+
export function resolveConfig(config: ClockNextConfig): ResolvedConfig {
|
|
74
|
+
if (!config.apiKey || typeof config.apiKey !== "string") {
|
|
75
|
+
// A ClockNextError, not a bare Error: a caller wrapping setup in
|
|
76
|
+
// `catch (e) { if (e instanceof ClockNextError) … }` should not have this
|
|
77
|
+
// one class of failure escape the net.
|
|
78
|
+
throw new ValidationError("ClockNext: `apiKey` is required.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fetchImpl: FetchLike | undefined =
|
|
82
|
+
config.fetch ??
|
|
83
|
+
(typeof globalThis.fetch === "function"
|
|
84
|
+
? (globalThis.fetch.bind(globalThis) as FetchLike)
|
|
85
|
+
: undefined);
|
|
86
|
+
if (!fetchImpl) {
|
|
87
|
+
throw new ValidationError(
|
|
88
|
+
"ClockNext: no global `fetch` found. Use Node 18+, or pass a `fetch` implementation.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
apiKey: config.apiKey,
|
|
94
|
+
baseUrl: (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
|
|
95
|
+
mode: config.mode ?? "async",
|
|
96
|
+
timeoutMs: num(config.timeoutMs, 10_000, 1),
|
|
97
|
+
batch: {
|
|
98
|
+
maxSize: num(config.batch?.maxSize, 20, 1),
|
|
99
|
+
maxIntervalMs: num(config.batch?.maxIntervalMs, 2000, 0),
|
|
100
|
+
maxConcurrency: num(config.batch?.maxConcurrency, 5, 1),
|
|
101
|
+
maxQueueSize: num(config.batch?.maxQueueSize, 10_000, 1),
|
|
102
|
+
},
|
|
103
|
+
retry: {
|
|
104
|
+
maxAttempts: num(config.retry?.maxAttempts, 5, 1),
|
|
105
|
+
baseDelayMs: num(config.retry?.baseDelayMs, 200, 0),
|
|
106
|
+
maxDelayMs: num(config.retry?.maxDelayMs, 10_000, 0),
|
|
107
|
+
},
|
|
108
|
+
fetch: fetchImpl,
|
|
109
|
+
logger: config.logger ?? {},
|
|
110
|
+
// Both default to loud. See the note above `defaultOnError`.
|
|
111
|
+
onError: config.onError ?? defaultOnError,
|
|
112
|
+
onFlush: config.onFlush,
|
|
113
|
+
onRetry: config.onRetry,
|
|
114
|
+
onDrop: config.onDrop ?? defaultOnDrop,
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error hierarchy. The server returns `{ statusCode, statusDetail, result }`
|
|
3
|
+
* with a meaningful HTTP status; we map that status to a class so callers can
|
|
4
|
+
* branch on the *kind* of failure (e.g. allowance exhausted ⇒ upsell; network ⇒
|
|
5
|
+
* ignore, it's queued) rather than string-matching messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Machine-readable failure code, when the server sends one
|
|
9
|
+
* (`statusDetail.code`). Preferred over any message heuristic. */
|
|
10
|
+
export type ClockNextErrorCode =
|
|
11
|
+
| "ALLOWANCE_EXHAUSTED"
|
|
12
|
+
| "PLAN_REJECTED"
|
|
13
|
+
| "IDEMPOTENCY_UNRESOLVED"
|
|
14
|
+
| (string & {});
|
|
15
|
+
|
|
16
|
+
export class ClockNextError extends Error {
|
|
17
|
+
/** HTTP status, when the failure came from a server response. */
|
|
18
|
+
readonly status?: number;
|
|
19
|
+
/** Whether the SDK considers this failure worth retrying. */
|
|
20
|
+
readonly retryable: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The server's machine-readable code, when it sent one. Branch on this in
|
|
23
|
+
* preference to `.message`, which is human copy and may be reworded.
|
|
24
|
+
*/
|
|
25
|
+
readonly code?: ClockNextErrorCode;
|
|
26
|
+
/**
|
|
27
|
+
* The idempotency key of the request that failed, for metering calls.
|
|
28
|
+
*
|
|
29
|
+
* This is what makes a dead-letter retry safe: re-send the failed signal with
|
|
30
|
+
* this SAME key and the server deduplicates it instead of billing twice. A
|
|
31
|
+
* fresh key would double-bill, so never generate one when replaying.
|
|
32
|
+
*/
|
|
33
|
+
readonly idempotencyKey?: string;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
message: string,
|
|
37
|
+
opts: {
|
|
38
|
+
status?: number;
|
|
39
|
+
retryable?: boolean;
|
|
40
|
+
code?: ClockNextErrorCode;
|
|
41
|
+
idempotencyKey?: string;
|
|
42
|
+
} = {},
|
|
43
|
+
) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = new.target.name;
|
|
46
|
+
this.status = opts.status;
|
|
47
|
+
this.retryable = opts.retryable ?? false;
|
|
48
|
+
this.code = opts.code;
|
|
49
|
+
this.idempotencyKey = opts.idempotencyKey;
|
|
50
|
+
// Restore prototype chain for instanceof across transpile targets.
|
|
51
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 401 — missing / invalid / expired API key. */
|
|
56
|
+
export class AuthError extends ClockNextError {}
|
|
57
|
+
|
|
58
|
+
/** 403 — the key is valid but cannot perform this operation. */
|
|
59
|
+
export class ForbiddenError extends ClockNextError {}
|
|
60
|
+
|
|
61
|
+
/** 400 — malformed request / schema validation failure. */
|
|
62
|
+
export class ValidationError extends ClockNextError {}
|
|
63
|
+
|
|
64
|
+
/** 404 — unknown customer (also returned for cross-org access). */
|
|
65
|
+
export class NotFoundError extends ClockNextError {}
|
|
66
|
+
|
|
67
|
+
/** 422 — no active plan, meter not in plan, model not enabled, member missing. */
|
|
68
|
+
export class PlanError extends ClockNextError {}
|
|
69
|
+
|
|
70
|
+
/** 422 — the entitlement ran out: no credits / units / outcomes / composite
|
|
71
|
+
* slots left. Distinct from {@link PlanError} because the fix is different —
|
|
72
|
+
* this one is a top-up or an upsell, not a configuration change. */
|
|
73
|
+
export class AllowanceError extends ClockNextError {}
|
|
74
|
+
|
|
75
|
+
/** 409 — conflict. Almost always permanent (an archived customer); only the
|
|
76
|
+
* idempotency-resolution conflict is transient, and only that one retries. */
|
|
77
|
+
export class ConflictError extends ClockNextError {}
|
|
78
|
+
|
|
79
|
+
/** 429 — rate limited. */
|
|
80
|
+
export class RateLimitError extends ClockNextError {
|
|
81
|
+
readonly retryAfterMs?: number;
|
|
82
|
+
constructor(
|
|
83
|
+
message: string,
|
|
84
|
+
opts: { retryAfterMs?: number; idempotencyKey?: string } = {},
|
|
85
|
+
) {
|
|
86
|
+
super(message, {
|
|
87
|
+
status: 429,
|
|
88
|
+
retryable: true,
|
|
89
|
+
idempotencyKey: opts.idempotencyKey,
|
|
90
|
+
});
|
|
91
|
+
this.retryAfterMs = opts.retryAfterMs;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 5xx — server-side error. */
|
|
96
|
+
export class ServerError extends ClockNextError {}
|
|
97
|
+
|
|
98
|
+
/** fetch threw / request timed out — request never completed. */
|
|
99
|
+
export class NetworkError extends ClockNextError {
|
|
100
|
+
constructor(message: string, opts: { idempotencyKey?: string } = {}) {
|
|
101
|
+
super(message, { retryable: true, idempotencyKey: opts.idempotencyKey });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Wire field path → the SDK field the caller actually wrote.
|
|
107
|
+
*
|
|
108
|
+
* The server validates the WIRE body and names the offending path in its 400
|
|
109
|
+
* (`usage.inputTokens: Expected number…`). But the SDK groups those fields
|
|
110
|
+
* differently — `tokens: { input, output, cache }` — so the path it reports
|
|
111
|
+
* names something that does not exist anywhere in this SDK's API. A caller
|
|
112
|
+
* (human or LLM) then searches their own code for `usage.inputTokens`, finds
|
|
113
|
+
* nothing, and has no next move.
|
|
114
|
+
*
|
|
115
|
+
* Rewriting the path closes that loop: the error names the property the caller
|
|
116
|
+
* typed. Ordered longest-first so `usage.model` is rewritten before a bare
|
|
117
|
+
* `usage` prefix could shadow it.
|
|
118
|
+
*/
|
|
119
|
+
const FIELD_PATHS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
120
|
+
[/\busage\.inputTokens\b/g, "tokens.input"],
|
|
121
|
+
[/\busage\.outputTokens\b/g, "tokens.output"],
|
|
122
|
+
[/\busage\.cacheTokens\b/g, "tokens.cache"],
|
|
123
|
+
[/\busage\.model\b/g, "model"],
|
|
124
|
+
// A missing/invalid `usage` block as a whole: the caller's equivalent is
|
|
125
|
+
// `tokens` (plus `model`, which the SDK hoists to the top level).
|
|
126
|
+
[/^usage:/, "tokens:"],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
/** Rewrite wire field paths in a validation message to SDK field paths. */
|
|
130
|
+
export function translateFieldPaths(message: string): string {
|
|
131
|
+
let out = message;
|
|
132
|
+
for (const [pattern, replacement] of FIELD_PATHS) {
|
|
133
|
+
out = out.replace(pattern, replacement);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether a 422 means "the entitlement ran out" rather than "the plan does not
|
|
140
|
+
* allow this".
|
|
141
|
+
*
|
|
142
|
+
* Both share a status, so they have to be told apart from the body. Prefer the
|
|
143
|
+
* server's `code`; fall back to matching the exhaustion messages the signal
|
|
144
|
+
* engine actually emits:
|
|
145
|
+
*
|
|
146
|
+
* No <name> outcomes remaining for this customer. Top up the allowance, …
|
|
147
|
+
* No allowance left for "<unit>" — N of M remaining. Top up the balance, …
|
|
148
|
+
* No slots left in composite "<name>" — all N are claimed for this cycle.
|
|
149
|
+
*
|
|
150
|
+
* Note what is NOT here: `insufficient`. Earlier versions of this SDK tested
|
|
151
|
+
* for that word alone, and it appears nowhere in the ClockNext API — so
|
|
152
|
+
* `AllowanceError` was unreachable and every exhaustion arrived as a
|
|
153
|
+
* `PlanError`. The word is kept in the pattern only so a future rewording
|
|
154
|
+
* toward it still classifies, never as the sole test.
|
|
155
|
+
*/
|
|
156
|
+
const ALLOWANCE_MESSAGE =
|
|
157
|
+
/\binsufficient\b|\bno allowance left\b|\bno slots left\b|\boutcomes remaining\b|\btop up the (?:allowance|balance)\b/i;
|
|
158
|
+
|
|
159
|
+
export function isAllowanceFailure(message: string, code?: string): boolean {
|
|
160
|
+
if (code) return code === "ALLOWANCE_EXHAUSTED";
|
|
161
|
+
return ALLOWANCE_MESSAGE.test(message);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The one 409 that is worth retrying.
|
|
166
|
+
*
|
|
167
|
+
* Every other conflict this API returns is `Customer "<id>" is archived.` —
|
|
168
|
+
* permanent, and retrying it only burns `retry.maxAttempts` rounds of backoff
|
|
169
|
+
* before failing anyway. This one is a lost race while resolving an idempotent
|
|
170
|
+
* replay, and it does clear.
|
|
171
|
+
*/
|
|
172
|
+
const TRANSIENT_CONFLICT = /could not resolve the idempotent request/i;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Maps a server response to a typed error.
|
|
176
|
+
*
|
|
177
|
+
* `code` is the server's machine-readable failure code when it sent one; it
|
|
178
|
+
* takes precedence over every message heuristic below.
|
|
179
|
+
*/
|
|
180
|
+
export function errorFromResponse(
|
|
181
|
+
status: number,
|
|
182
|
+
message: string,
|
|
183
|
+
retryAfterMs?: number,
|
|
184
|
+
meta: { code?: ClockNextErrorCode; idempotencyKey?: string } = {},
|
|
185
|
+
): ClockNextError {
|
|
186
|
+
const raw = message || `Request failed with status ${status}.`;
|
|
187
|
+
const { code, idempotencyKey } = meta;
|
|
188
|
+
const opts = { status, code, idempotencyKey };
|
|
189
|
+
|
|
190
|
+
if (status === 400) {
|
|
191
|
+
// Only validation messages carry wire field paths worth rewriting.
|
|
192
|
+
return new ValidationError(translateFieldPaths(raw), opts);
|
|
193
|
+
}
|
|
194
|
+
if (status === 401) return new AuthError(raw, opts);
|
|
195
|
+
if (status === 403) return new ForbiddenError(raw, opts);
|
|
196
|
+
if (status === 404) return new NotFoundError(raw, opts);
|
|
197
|
+
// 408 Request Timeout — transient; the request never produced a result, so
|
|
198
|
+
// a retry is safe.
|
|
199
|
+
if (status === 408) return new ClockNextError(raw, { ...opts, retryable: true });
|
|
200
|
+
if (status === 409) {
|
|
201
|
+
return new ConflictError(raw, {
|
|
202
|
+
...opts,
|
|
203
|
+
retryable:
|
|
204
|
+
code === "IDEMPOTENCY_UNRESOLVED" || TRANSIENT_CONFLICT.test(raw),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (status === 422) {
|
|
208
|
+
return isAllowanceFailure(raw, code)
|
|
209
|
+
? new AllowanceError(raw, opts)
|
|
210
|
+
: new PlanError(raw, opts);
|
|
211
|
+
}
|
|
212
|
+
if (status === 429) return new RateLimitError(raw, { retryAfterMs, idempotencyKey });
|
|
213
|
+
if (status >= 500) return new ServerError(raw, { ...opts, retryable: true });
|
|
214
|
+
return new ClockNextError(raw, opts);
|
|
215
|
+
}
|
package/src/filters.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `active` list filter, shared by every catalog resource.
|
|
3
|
+
*
|
|
4
|
+
* The API offers exactly ONE filter: `?active=true` narrows a list to active
|
|
5
|
+
* rows. There is no inactive-only parameter — the server tests
|
|
6
|
+
* `searchParams.get("active") === "true"` and returns everything otherwise.
|
|
7
|
+
*
|
|
8
|
+
* That made `{ active: false }` a trap. It reads as "only the archived ones",
|
|
9
|
+
* but it serialised to `active=false`, failed the server's equality test, and
|
|
10
|
+
* returned EVERY row — including all the active ones the caller was trying to
|
|
11
|
+
* exclude. The opposite of the request, with no error to notice.
|
|
12
|
+
*
|
|
13
|
+
* Rather than leave a boolean whose `false` branch means the reverse of what it
|
|
14
|
+
* says, `false` is honoured here instead: the query parameter is omitted and
|
|
15
|
+
* the active rows are filtered out client-side. Omitting `active` entirely
|
|
16
|
+
* still means "everything", which is the API's own default.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The `active` query value to send: only `true` narrows server-side. */
|
|
20
|
+
export function activeQuery(active?: boolean): string | undefined {
|
|
21
|
+
return active === true ? "true" : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Apply the half of the filter the API cannot do. A no-op unless `active` is
|
|
26
|
+
* explicitly `false`, in which case only archived rows survive.
|
|
27
|
+
*/
|
|
28
|
+
export function filterByActive<T extends { isActive: boolean }>(
|
|
29
|
+
rows: T[],
|
|
30
|
+
active?: boolean,
|
|
31
|
+
): T[] {
|
|
32
|
+
return active === false ? rows.filter((row) => !row.isActive) : rows;
|
|
33
|
+
}
|