@limitry/sdk 0.0.0-stage → 1.0.0-rc.3

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.
@@ -0,0 +1,110 @@
1
+ import type { BaseClient, Schemas } from "../index.ts";
2
+ /**
3
+ * Limitry's helpers (docs/product/roadmap.md slice 6): the two shapes a
4
+ * product's code takes around a costly call, so adding Limitry is one
5
+ * wrapper — and an agent adding it writes the same thing every time.
6
+ *
7
+ * const limitry = createClient(); // reads LIMITRY_API_KEY
8
+ *
9
+ * // Known cost: check, then do the work only if allowed.
10
+ * const response = await limitry.guard(
11
+ * { subject: { kind: "apikey", id: key.id }, action: "api-request" },
12
+ * () => handle(request),
13
+ * );
14
+ *
15
+ * // Cost known afterwards (an AI reply's cents), or "at most N at
16
+ * // once": hold an estimate, do the work, commit what it used —
17
+ * // released on failure.
18
+ * const answer = await limitry.hold(
19
+ * { subject: { kind: "user", id: user.id }, action: "ai-call", estimate: 5 },
20
+ * async (held) => {
21
+ * const res = await callModel(prompt);
22
+ * held.cost(priceCents(res.usage));
23
+ * return res;
24
+ * },
25
+ * );
26
+ *
27
+ * A refusal throws `LimitExceeded` (with `retryAfterSeconds`). When
28
+ * Limitry cannot be reached (an error, a 5xx, or no answer within
29
+ * `timeoutMs`) — or the workspace is past its plan's allowance (402
30
+ * `plan_limit_reached`) — each limit's fail mode decides: the last decision's
31
+ * `failMode` for this subject and action is remembered in memory, and
32
+ * `closed` refuses (`LimitExceeded` with reason `unavailable`) while
33
+ * `open` — the default, and the answer before any decision was seen —
34
+ * lets the work run unmetered.
35
+ *
36
+ * Retries are safe (docs/product idempotency): each guard or hold sends
37
+ * one Idempotency-Key, which the client's own retries reuse, so a check
38
+ * counts once however often it is sent. A hold whose check timed out may
39
+ * have been granted all the same: once the work is done, `hold` sends the
40
+ * check again with the same key — a replay, nothing counted twice — and
41
+ * releases the hold it finds, rather than leave it taken until it expires.
42
+ * Commits and releases retry too (settling twice answers the same).
43
+ */
44
+ type Decision = Schemas["CheckDecision"];
45
+ type Subject = {
46
+ kind: string;
47
+ id: string;
48
+ };
49
+ export type LimitsOptions = {
50
+ /** How long to wait for a decision before the fail mode applies. Default 1500 ms. */
51
+ timeoutMs?: number;
52
+ /** The fail mode before any decision was seen, or to force one. Default: remembered, else `open`. */
53
+ failMode?: "open" | "closed";
54
+ };
55
+ export type GuardRequest = {
56
+ subject: Subject;
57
+ action: string;
58
+ /** Units it uses. Default 1. */
59
+ cost?: number;
60
+ /** Pools it also draws from (a team's, an org's). */
61
+ shared?: Subject[];
62
+ };
63
+ export type HoldRequest = {
64
+ subject: Subject;
65
+ action: string;
66
+ /** What it will probably use — held until you commit the actual cost. Default 1. */
67
+ estimate?: number;
68
+ shared?: Subject[];
69
+ /** How long the hold lasts if never settled (default 300, at most 3600). */
70
+ ttlSeconds?: number;
71
+ };
72
+ export type Held = {
73
+ /** The cost actually used — committed when the work returns. Default: the estimate. */
74
+ cost(units: number): void;
75
+ /** Keep the hold for longer work: it expires `ttlSeconds` from now. */
76
+ extend(ttlSeconds?: number): Promise<void>;
77
+ };
78
+ /** Not allowed: a limit is used up — or Limitry is unreachable and a limit fails closed. */
79
+ export declare class LimitExceeded extends Error {
80
+ readonly reason: "limit_exceeded" | "too_many_reservations" | "unavailable";
81
+ /** The limit that refused; null when unavailable or too many holds. */
82
+ readonly limit: string | null;
83
+ readonly retryAfterSeconds: number | null;
84
+ readonly decision: Decision | null;
85
+ constructor(decision: Decision | null);
86
+ }
87
+ export declare function createLimits(client: BaseClient, options?: LimitsOptions): {
88
+ /**
89
+ * Where a subject stands on every limit on it (and on `shared`
90
+ * subjects): used, remaining, resetsAt — for showing an end user. Call
91
+ * it from your server; a key never belongs in a browser. Not a check:
92
+ * nothing is counted; a subject with no limit gets an empty `limits`.
93
+ * Throws on an error answer.
94
+ */
95
+ standing(request: {
96
+ subject: Subject;
97
+ shared?: Subject[];
98
+ }): Promise<Schemas["UsageStanding"]>;
99
+ /** Checks `cost`, runs `fn` only if allowed, and returns its result. */
100
+ guard<T>(request: GuardRequest, fn: () => Promise<T>): Promise<T>;
101
+ /**
102
+ * Holds `estimate`, runs `fn`, then commits the cost it reported
103
+ * (`held.cost(n)`, default the estimate) — or releases the hold if
104
+ * `fn` throws, and rethrows. A concurrent limit's slot is held for
105
+ * exactly as long as `fn` runs.
106
+ */
107
+ hold<T>(request: HoldRequest, fn: (held: Held) => Promise<T>): Promise<T>;
108
+ };
109
+ export type Limits = ReturnType<typeof createLimits>;
110
+ export {};
@@ -0,0 +1,234 @@
1
+ /** Not allowed: a limit is used up — or Limitry is unreachable and a limit fails closed. */
2
+ export class LimitExceeded extends Error {
3
+ reason;
4
+ /** The limit that refused; null when unavailable or too many holds. */
5
+ limit;
6
+ retryAfterSeconds;
7
+ decision;
8
+ constructor(decision) {
9
+ const reason = decision?.reason?.code ?? "unavailable";
10
+ super(reason === "unavailable"
11
+ ? "Limitry could not be reached and a limit denies when unreachable"
12
+ : reason === "too_many_reservations"
13
+ ? "Too many open reservations for this subject"
14
+ : `Limit ${decision?.reason?.limit ?? ""} exceeded`.trim());
15
+ this.name = "LimitExceeded";
16
+ this.reason = reason;
17
+ this.limit = decision?.reason?.limit ?? null;
18
+ this.retryAfterSeconds = decision?.retryAfterSeconds ?? null;
19
+ this.decision = decision;
20
+ }
21
+ }
22
+ const UNREACHABLE = Symbol("unreachable");
23
+ /** No answer in time: unlike an error, the check may have been decided. */
24
+ const TIMED_OUT = Symbol("timed out");
25
+ /** How long a settle (or a replay to find a hold) may take, retries included. */
26
+ const SETTLE_TIMEOUT_MS = 5000;
27
+ export function createLimits(client, options = {}) {
28
+ const timeoutMs = options.timeoutMs ?? 1500;
29
+ /** The last fail mode seen per subject + action (closed if any limit is). */
30
+ const remembered = new Map();
31
+ const key = (s, action) => `${s.kind}\u0000${s.id}\u0000${action}`;
32
+ const failMode = (s, action) => options.failMode ?? remembered.get(key(s, action)) ?? "open";
33
+ const remember = (s, action, decision) => {
34
+ if (options.failMode)
35
+ return;
36
+ const closed = decision.remaining.some((r) => r.failMode === "closed");
37
+ remembered.set(key(s, action), closed ? "closed" : "open");
38
+ if (remembered.size > 10_000) {
39
+ remembered.delete(remembered.keys().next().value);
40
+ }
41
+ };
42
+ /**
43
+ * The decision — or UNREACHABLE (network error, 5xx, past the plan),
44
+ * or TIMED_OUT (no answer within `wait`).
45
+ */
46
+ async function check(body, idempotencyKey, wait = timeoutMs) {
47
+ let timer;
48
+ // Raced as well as aborted: a custom fetch (a service binding, a
49
+ // test) may ignore the signal.
50
+ const late = new Promise((resolve) => {
51
+ timer = setTimeout(() => resolve(TIMED_OUT), wait);
52
+ });
53
+ const asked = (async () => {
54
+ try {
55
+ const { data, error, response } = await client.POST("/checks", {
56
+ body,
57
+ headers: { "idempotency-key": idempotencyKey },
58
+ signal: AbortSignal.timeout(wait),
59
+ });
60
+ if (data)
61
+ return data;
62
+ // Past the plan's allowance (402 plan_limit_reached): no decision
63
+ // — each limit's fail mode applies, as if Limitry were unreachable.
64
+ if (response.status === 402)
65
+ return UNREACHABLE;
66
+ // A 4xx is the caller's mistake (bad request, bad key): say so.
67
+ if (response.status < 500 && error) {
68
+ return new Error(`Limitry refused the check (${response.status}): ${JSON.stringify(error)}`);
69
+ }
70
+ return UNREACHABLE;
71
+ }
72
+ catch (error) {
73
+ return error?.name === "TimeoutError"
74
+ ? TIMED_OUT
75
+ : UNREACHABLE;
76
+ }
77
+ })();
78
+ try {
79
+ const answer = await Promise.race([asked, late]);
80
+ if (answer instanceof Error)
81
+ throw answer;
82
+ return answer;
83
+ }
84
+ finally {
85
+ clearTimeout(timer);
86
+ }
87
+ }
88
+ /**
89
+ * Settling never fails the caller's work: the client retries it (the
90
+ * same settle answers the same), and a settle lost all the same expires
91
+ * by itself.
92
+ */
93
+ async function settle(id, action, cost) {
94
+ try {
95
+ if (action === "commit") {
96
+ await client.POST("/reservations/{reservationId}/commit", {
97
+ params: { path: { reservationId: id } },
98
+ body: { cost: cost ?? 0 },
99
+ signal: AbortSignal.timeout(SETTLE_TIMEOUT_MS),
100
+ });
101
+ }
102
+ else {
103
+ await client.POST("/reservations/{reservationId}/release", {
104
+ params: { path: { reservationId: id } },
105
+ signal: AbortSignal.timeout(SETTLE_TIMEOUT_MS),
106
+ });
107
+ }
108
+ }
109
+ catch {
110
+ // The hold expires on its own.
111
+ }
112
+ }
113
+ /**
114
+ * After a hold's check timed out: ask again with the same key (a replay
115
+ * if Limitry decided it) and release any hold it was granted.
116
+ */
117
+ async function releaseUnheard(body, idempotencyKey) {
118
+ const replay = await check(body, idempotencyKey, SETTLE_TIMEOUT_MS);
119
+ if (typeof replay === "object" && replay.reservation) {
120
+ await settle(replay.reservation.id, "release");
121
+ }
122
+ }
123
+ return {
124
+ /**
125
+ * Where a subject stands on every limit on it (and on `shared`
126
+ * subjects): used, remaining, resetsAt — for showing an end user. Call
127
+ * it from your server; a key never belongs in a browser. Not a check:
128
+ * nothing is counted; a subject with no limit gets an empty `limits`.
129
+ * Throws on an error answer.
130
+ */
131
+ async standing(request) {
132
+ const { data, error, response } = await client.GET("/usage/standing", {
133
+ params: {
134
+ query: {
135
+ kind: request.subject.kind,
136
+ id: request.subject.id,
137
+ ...(request.shared?.length
138
+ ? {
139
+ shared: request.shared
140
+ .map((s) => `${s.kind}:${s.id}`)
141
+ .join(","),
142
+ }
143
+ : {}),
144
+ },
145
+ },
146
+ });
147
+ if (data)
148
+ return data;
149
+ throw new Error(`Limitry refused usage.standing (${response.status}): ${JSON.stringify(error)}`);
150
+ },
151
+ /** Checks `cost`, runs `fn` only if allowed, and returns its result. */
152
+ async guard(request, fn) {
153
+ const decision = await check({
154
+ subject: request.subject,
155
+ shared: request.shared,
156
+ action: request.action,
157
+ cost: request.cost ?? 1,
158
+ }, crypto.randomUUID());
159
+ if (decision === UNREACHABLE || decision === TIMED_OUT) {
160
+ if (failMode(request.subject, request.action) === "closed") {
161
+ throw new LimitExceeded(null);
162
+ }
163
+ return fn();
164
+ }
165
+ remember(request.subject, request.action, decision);
166
+ if (!decision.allowed)
167
+ throw new LimitExceeded(decision);
168
+ return fn();
169
+ },
170
+ /**
171
+ * Holds `estimate`, runs `fn`, then commits the cost it reported
172
+ * (`held.cost(n)`, default the estimate) — or releases the hold if
173
+ * `fn` throws, and rethrows. A concurrent limit's slot is held for
174
+ * exactly as long as `fn` runs.
175
+ */
176
+ async hold(request, fn) {
177
+ const estimate = request.estimate ?? 1;
178
+ const body = {
179
+ subject: request.subject,
180
+ shared: request.shared,
181
+ action: request.action,
182
+ cost: estimate,
183
+ mode: "reserve",
184
+ ttlSeconds: request.ttlSeconds,
185
+ };
186
+ const idempotencyKey = crypto.randomUUID();
187
+ const decision = await check(body, idempotencyKey);
188
+ const unmetered = { cost: () => { }, extend: async () => { } };
189
+ if (decision === UNREACHABLE || decision === TIMED_OUT) {
190
+ const unheard = () => decision === TIMED_OUT
191
+ ? releaseUnheard(body, idempotencyKey)
192
+ : Promise.resolve();
193
+ if (failMode(request.subject, request.action) === "closed") {
194
+ await unheard();
195
+ throw new LimitExceeded(null);
196
+ }
197
+ try {
198
+ return await fn(unmetered);
199
+ }
200
+ finally {
201
+ await unheard();
202
+ }
203
+ }
204
+ remember(request.subject, request.action, decision);
205
+ if (!decision.allowed || !decision.reservation) {
206
+ throw new LimitExceeded(decision);
207
+ }
208
+ const id = decision.reservation.id;
209
+ let used = estimate;
210
+ const held = {
211
+ cost(units) {
212
+ used = units;
213
+ },
214
+ async extend(ttlSeconds) {
215
+ await client.POST("/reservations/{reservationId}/extend", {
216
+ params: { path: { reservationId: id } },
217
+ body: ttlSeconds ? { ttlSeconds } : {},
218
+ signal: AbortSignal.timeout(timeoutMs),
219
+ });
220
+ },
221
+ };
222
+ let result;
223
+ try {
224
+ result = await fn(held);
225
+ }
226
+ catch (error) {
227
+ await settle(id, "release");
228
+ throw error;
229
+ }
230
+ await settle(id, "commit", used);
231
+ return result;
232
+ },
233
+ };
234
+ }
@@ -0,0 +1,71 @@
1
+ import type { Client as OpenApiClient } from "openapi-fetch";
2
+ import type { paths } from "./v1.gen.ts";
3
+ /**
4
+ * The SDK's RESOURCE METHODS — `client.limits.list()`,
5
+ * `client.runs.create(slug, { inputs })` — one per /v1 operation, named
6
+ * as the CLI names it (`limits list`), generated into ./resources.gen.ts
7
+ * from the same operation table as the CLI's commands and the MCP
8
+ * server's tools (scripts/generate.mjs; gitignored, regenerated by every
9
+ * build). Path parameters are arguments, in path order; query and body
10
+ * fields are one input object, typed from ./v1.gen.ts; the answer is the
11
+ * response's data, and a non-2xx throws ApiError. Decided 2026-10-11,
12
+ * before 1.0: nobody guesses `api.POST("/scripts/{slug}/runs", { params:
13
+ * { path: { slug } }, body })` wrapped in `unwrap(...)`. The names are
14
+ * the CLI's, so the surface snapshot (surfaces.test.ts) covers them.
15
+ */
16
+ export type RawClient = OpenApiClient<paths>;
17
+ type Method = "get" | "post" | "put" | "patch" | "delete";
18
+ type Op<P extends keyof paths, M extends Method> = M extends keyof paths[P] ? paths[P][M] : never;
19
+ type NonEmpty<T, Else> = [NonNullable<T>] extends [never] ? Else : NonNullable<T>;
20
+ type QueryOf<O> = O extends {
21
+ parameters: {
22
+ query?: infer Q;
23
+ };
24
+ } ? NonEmpty<Q, unknown> : unknown;
25
+ type BodyOf<O> = O extends {
26
+ requestBody?: infer R;
27
+ } ? NonEmpty<R, never> extends {
28
+ content: {
29
+ "application/json": infer B;
30
+ };
31
+ } ? B : unknown : unknown;
32
+ /** An operation's query and body fields, as one object. */
33
+ export type Input<P extends keyof paths, M extends Method> = QueryOf<Op<P, M>> & BodyOf<Op<P, M>>;
34
+ type Json<R> = R extends {
35
+ content: {
36
+ "application/json": infer J;
37
+ };
38
+ } ? J : undefined;
39
+ /** What an operation answers on success (its 2xx JSON). */
40
+ export type Output<P extends keyof paths, M extends Method> = Op<P, M> extends {
41
+ responses: infer R;
42
+ } ? {
43
+ [K in keyof R]: K extends 200 | 201 | 202 ? Json<R[K]> : K extends 204 ? undefined : never;
44
+ }[keyof R] : never;
45
+ export type RequestOptions = {
46
+ /**
47
+ * Sent as Idempotency-Key. Every write gets one anyway (one per call,
48
+ * reused on the client's own retries); pass yours to make a retry
49
+ * YOU make — a rerun job, a redelivered webhook — count once too.
50
+ */
51
+ idempotencyKey?: string;
52
+ /** More request headers. */
53
+ headers?: Record<string, string>;
54
+ };
55
+ /** One operation's request, as the generated method describes it. */
56
+ export type Call = {
57
+ method: Method;
58
+ path: string;
59
+ /** The input's fields that go in the query string; the rest is the body. */
60
+ query: readonly string[];
61
+ hasBody: boolean;
62
+ /** Takes raw bytes as the body (the route's media type). */
63
+ rawBody?: string;
64
+ /** Answers with raw bytes: the method returns a Blob. */
65
+ rawResponse?: string;
66
+ };
67
+ /** Raw bytes for an operation that takes them, such as a file upload. */
68
+ export type Content = Blob | ArrayBuffer | Uint8Array | string;
69
+ /** Makes the call; the data, or throws ApiError. */
70
+ export declare function call<T>(client: RawClient, op: Call, pathParams: Record<string, string | number>, input: object | undefined, options?: RequestOptions, content?: Content): Promise<T>;
71
+ export {};