@volter/twin-openai 0.1.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.
@@ -0,0 +1,173 @@
1
+ // OpenAI's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the thin typed
2
+ // bindings `liveOpenAIExecute` uses. The MECHANISM — the durable token-keyed ledger, the rolling
3
+ // window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger —
4
+ // lives ONCE in the vendor-agnostic kernel (`@volter/twin` → `rateBudget.ts`). Read that module's
5
+ // header for the full rationale AND for the honest list of what the guard does not guarantee (an
6
+ // injected clock or ledger path still defeats it — it guards carelessness, not malice).
7
+ //
8
+ // ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────────────────
9
+ // A real ~4.5-DAY vendor lockout (Figma, 2026-07-25) happened because raw API calls were made
10
+ // outside the pack's connector — no cache, no batching, no ceiling. Discipline only binds the code
11
+ // that follows it; a BUDGET binds the code that does not.
12
+ //
13
+ // ── HOW THE CEILING WAS CHOSEN: NO SCALAR IS PUBLISHED ──────────────────────────────────────
14
+ // Unlike Notion or Cal.com, OpenAI publishes NO scalar per-key request limit. From
15
+ // https://developers.openai.com/api/docs/guides/rate-limits (read 2026-07-26): limits are per
16
+ // ORGANIZATION and PROJECT, expressed in RPM/RPD/TPM/TPD/IPM, and they differ PER MODEL and per
17
+ // tier — the actual figures live on the account's own limits page, not in the docs. The docs carry
18
+ // exactly one scalar this pack could be bound by: Vector Store ingestion is 300 requests per minute
19
+ // per vector store ID. The Batch API's limit is a queued-input-TOKEN quota, not an RPM.
20
+ //
21
+ // So this budget deliberately does NOT model the vendor's limit, and the numbers below are NOT
22
+ // derived from one. Because there is no documented figure to justify going higher, the ceiling is
23
+ // pinned at the kernel's own undeclared fallback in every dimension: 60 weighted units per 60s at
24
+ // `defaultWeight` 2 — 30 calls a minute, exactly `DEFAULT_RATE_BUDGET`, with no endpoint priced
25
+ // CHEAPER than the fallback would price it. What this declaration adds over the fallback is
26
+ // therefore not headroom, it is RESOLUTION: the endpoints that cost real money get priced up.
27
+ //
28
+ // It bounds the 60s AVERAGE; it does not pace (the kernel refuses, it never sleeps). The backstop
29
+ // for a sub-second burst is the cooldown: OpenAI's `x-ratelimit-remaining-requests: 0` and its
30
+ // `retry-after` are both read off the response and turn into a persisted refusal.
31
+ //
32
+ // ── HOW THE WEIGHTS WERE CHOSEN (and what is a judgement call) ───────────────────────────────
33
+ // OpenAI counts requests per endpoint family, so a flat price is the faithful default. Two
34
+ // deliberate exceptions, both judgement calls rather than published costs:
35
+ // • The token-metered inference paths (`/v1/chat/completions`, `/v1/responses`, `/v1/embeddings`,
36
+ // `/v1/images/*`, `/v1/audio/*`) cost 5. There TPM — not RPM — is the binding limit, so one
37
+ // request is worth far more of the org's real allowance than a file-list poll. The connector
38
+ // never calls them, but the executor is generic and a caller could; an unclassified expensive
39
+ // endpoint is exactly the shape that caused the lockout.
40
+ // • Vector-store FILE ingestion costs 4, because it is the one endpoint with a documented
41
+ // per-minute scalar (300/min per store) AND the recursive fan-out shape — one store ingest is a
42
+ // loop over every file. At weight 4 at most 15 land in a window, a twentieth of that 300.
43
+ import {
44
+ declareRateBudget,
45
+ rateBudgetPath,
46
+ rateBudgetWeight,
47
+ RateBudget,
48
+ type RateBudgetDeclaration,
49
+ type RateBudgetOptions,
50
+ type RateBudgetReservation,
51
+ type RateBudgetSnapshot,
52
+ } from '@volter/twin';
53
+
54
+ const VENDOR = 'openai';
55
+
56
+ /** Rolling window, in ms. Spend older than this is pruned. */
57
+ export const OPENAI_BUDGET_WINDOW_MS = 60_000;
58
+
59
+ /**
60
+ * Weighted units allowed inside one window. 60/60s at `defaultWeight` 2 = 30 calls a minute —
61
+ * EXACTLY the kernel's undeclared fallback, because OpenAI publishes no scalar that would justify
62
+ * more. See the header.
63
+ */
64
+ export const OPENAI_BUDGET_CEILING = 60;
65
+
66
+ /** Seconds. A `retry-after` above this means the key is throttled hard — fail loudly, don't sleep. */
67
+ export const OPENAI_BUDGET_MAX_RETRY_AFTER_S = 300;
68
+
69
+ /** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is documented vs. judged. */
70
+ export const OPENAI_CALL_WEIGHTS = {
71
+ /** `/v1/chat/completions`, `/v1/responses`, `/v1/embeddings`, `/v1/images/*`, `/v1/audio/*` —
72
+ * token/compute-metered, where TPM rather than RPM is the binding limit. */
73
+ inference: 5,
74
+ /** `POST /v1/vector_stores/:id/files` — documented 300 RPM per store, and a per-file fan-out. */
75
+ ingest: 4,
76
+ /** Everything else: file/batch/fine-tune/vector-store list, retrieve, create, cancel, delete. */
77
+ other: 2,
78
+ } as const;
79
+
80
+ /** THE PACK'S DECLARATION — pure data, the only OpenAI-specific thing in the whole budget. */
81
+ export const OPENAI_RATE_BUDGET: RateBudgetDeclaration = {
82
+ windowMs: OPENAI_BUDGET_WINDOW_MS,
83
+ ceiling: OPENAI_BUDGET_CEILING,
84
+ defaultWeight: OPENAI_CALL_WEIGHTS.other,
85
+ maxRetryAfterSeconds: OPENAI_BUDGET_MAX_RETRY_AFTER_S,
86
+ rules: [
87
+ { match: '^POST /v1/(chat/completions|responses|embeddings|moderations)$', weight: OPENAI_CALL_WEIGHTS.inference },
88
+ { match: '^POST /v1/(images|audio|realtime|videos)/', weight: OPENAI_CALL_WEIGHTS.inference },
89
+ { match: '^POST /v1/vector_stores/[^/]+/files', weight: OPENAI_CALL_WEIGHTS.ingest },
90
+ ],
91
+ reason:
92
+ 'OpenAI publishes NO scalar per-key request limit (developers.openai.com/api/docs/guides/' +
93
+ 'rate-limits, read 2026-07-26): limits are per organization and project, in RPM/RPD/TPM/TPD/IPM, ' +
94
+ "differ per model and tier, and the figures live on the account's own limits page. The one " +
95
+ 'documented scalar that can bind this pack is Vector Store ingestion at 300 requests/minute per ' +
96
+ 'store. Because no published figure justifies going higher, the ceiling is pinned at the kernel ' +
97
+ "fallback in EVERY dimension — 60 units / 60s at defaultWeight 2 = 30 calls/min, and no endpoint " +
98
+ 'is priced cheaper than the fallback would price it; the declaration buys resolution, not ' +
99
+ 'headroom. Inference paths cost 5 (TPM, not RPM, binds them) and vector-store file ingestion ' +
100
+ 'costs 4 (the documented 300/min scalar, and a per-file fan-out) — judgement calls, not published ' +
101
+ 'costs. The window bounds the 60s AVERAGE and does not pace; the 429/`x-ratelimit-remaining-*: 0` ' +
102
+ 'cooldown is the backstop for a sub-second burst.',
103
+ };
104
+
105
+ // Declared at module load, so merely importing this module (which `openai-connector.ts` does) is
106
+ // enough to arm the real ceiling. `RateBudget` reads its policy live precisely so this declaration
107
+ // takes effect the moment it lands, and constructing through the subclass below (which imports this
108
+ // module) is what makes the ordering a non-issue in practice.
109
+ declareRateBudget(VENDOR, OPENAI_RATE_BUDGET);
110
+
111
+ /**
112
+ * Price one call. The key is `"<METHOD> <path>"` with the query string split off, so a rule can
113
+ * price by method (a write is not a read) without the kernel knowing anything about OpenAI. An
114
+ * unclassified endpoint still costs `defaultWeight` — nothing is ever free.
115
+ */
116
+ export function openaiCallWeight(method: string, path: string): number {
117
+ const { bare, query } = splitQuery(path);
118
+ // UPPER-CASE the method: `fetch` normalizes a known lowercase method before sending, so
119
+ // `execute('post', …)` really does issue a POST and must be priced as one.
120
+ return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
121
+ }
122
+
123
+ /**
124
+ * `/v1/x?a=1` -> `{ bare: '/v1/x', query: { a: '1' } }`. Rules match the path; `whenQuery*` the query.
125
+ *
126
+ * NORMALIZED, because the anchored rules are otherwise trivially evaded (§9 finding, 2026-07-26):
127
+ * `fetch` upper-cases a known method before sending, so `execute('post', …)` issues a real WRITE
128
+ * that a `^POST ` rule would price as a read; and a trailing slash makes a path miss a `$` anchor
129
+ * while most routers treat it as the same endpoint. Both are input variations, not attacks, and
130
+ * either one silently voids the "expensive endpoints are priced up" claim the ceiling rests on.
131
+ */
132
+ function splitQuery(path: string): { bare: string; query: Record<string, string> } {
133
+ const at = path.indexOf('?');
134
+ const query: Record<string, string> = {};
135
+ if (at !== -1) for (const [k, v] of new URLSearchParams(path.slice(at + 1))) query[k] = v;
136
+ const raw = at === -1 ? path : path.slice(0, at);
137
+ // Collapse a trailing slash, but never turn the root path into the empty string.
138
+ const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
139
+ return { bare, query };
140
+ }
141
+
142
+ /** Where OpenAI's ledger lives. Token-keyed and cwd-independent by default (the limit is per
143
+ * organization/project, i.e. per key, so a cwd-scoped ledger would hand the same key a fresh
144
+ * allowance in every checkout, worktree and CI matrix leg); pass `root` for world-scoped accounting. */
145
+ export function openaiBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
146
+ const o = typeof opts === 'string' ? { root: opts } : opts;
147
+ // VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
148
+ // excess-property check only catches object literals) must not redirect this pack's ledger to
149
+ // another vendor's file.
150
+ return rateBudgetPath({ ...o, vendor: VENDOR });
151
+ }
152
+
153
+ /** Construction options for OpenAI's budget. The vendor is fixed; everything else may only TIGHTEN. */
154
+ export type OpenAIBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
155
+
156
+ /**
157
+ * OpenAI's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
158
+ * not an alias, so `budget instanceof OpenAIBudget` in `liveOpenAIExecute` means "a budget that
159
+ * accounts against OPENAI's ledger under OPENAI's ceiling": another vendor's `RateBudget` (with its
160
+ * own, possibly larger, ceiling) is NOT assignable there.
161
+ */
162
+ export class OpenAIBudget extends RateBudget {
163
+ constructor(opts: OpenAIBudgetOptions = {}) {
164
+ super({ ...opts, vendor: VENDOR });
165
+ }
166
+ }
167
+
168
+ /** The typed refusal. One error class shared with every other vendor's budget; `err.vendor` says
169
+ * which one refused, and `err.kind` says why. */
170
+ export { RateBudgetError as OpenAIBudgetError } from '@volter/twin';
171
+ export type { RateBudgetErrorKind as OpenAIBudgetErrorKind } from '@volter/twin';
172
+ export type OpenAIBudgetReservation = RateBudgetReservation;
173
+ export type OpenAIBudgetSnapshot = RateBudgetSnapshot;