decision-gate 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.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+
package/NOTICE ADDED
@@ -0,0 +1,11 @@
1
+ decision-gate
2
+ Copyright 2026 Zachary Landes
3
+ Licensed under the Apache License, Version 2.0.
4
+
5
+ Redaction, spend budgeting and the Jev client are adapted from
6
+ zachlandes/herdr-find, commit 4736dd5 (Apache-2.0), originally developed in
7
+ Dewey, by way of jevzf.
8
+ Modified for shared key handling, durable spend reservations, a
9
+ cross-process rate limiter and an answer cache.
10
+
11
+ This project is unofficial and is not affiliated with TypeSafe.
package/README.md ADDED
@@ -0,0 +1,321 @@
1
+ # decision-gate
2
+
3
+ > **Pre-1.0.**
4
+ > This is 0.1.0: the interface may change, and a breaking change raises the minor version.
5
+ > Unofficial; not affiliated with TypeSafe.
6
+
7
+ Rate and spend limits for code that calls Jev in a loop.
8
+
9
+ If you ask Jev the same kind of question many times, say a go/no-go on every new job posting for each of fifty students, three things go wrong at volume.
10
+ Requests start failing with `429`, and every caller that retries on its own schedule makes it worse.
11
+ The same posting and question get sent twice, and you pay twice.
12
+ And nothing stops a runaway loop before the bill does.
13
+
14
+ decision-gate is the one place your code sends a Jev request through.
15
+ It waits for room under the account's rate limit, pauses every caller together when the service says to slow down, checks the request against a daily spend ceiling for your key, sends it through TypeSafe's own SDK and records what it cost.
16
+ It also gives you an answer cache, so a question answered in an earlier run, or earlier in the same run, is not asked again.
17
+ It never calls a generative model and never stores request text.
18
+
19
+ ## Quick start
20
+
21
+ ```sh
22
+ npm install decision-gate
23
+ export TYPESAFE_API_KEY=...
24
+ ```
25
+
26
+ ```js
27
+ import { openJev } from "decision-gate";
28
+
29
+ const questions = {
30
+ fit: { type: "noul", instructions: "Does this posting fit the student's skills and goals?" },
31
+ eligible: { type: "noul", instructions: "Can the student apply, given the location and their graduation date?" }
32
+ };
33
+
34
+ const jev = openJev({ tool: "job-screen" });
35
+ // Rewording any question changes the scope, so no old answers are reused
36
+ const cache = jev.cache({ scope: { questions } });
37
+ // Each run is capped at spend.per_run_usd (USD 0.02 by default); open a run per batch for a long job
38
+ const run = jev.run();
39
+
40
+ async function screen(posting, student) {
41
+ // Emails, known secret formats and authorization credentials are replaced before anything is sent or cached
42
+ const state = { posting: jev.redactor.redact(posting), student: jev.redactor.redact(student) };
43
+ const key = (name) => `${name}\n${JSON.stringify(state)}`;
44
+ const result = Object.fromEntries(Object.keys(questions).map((name) => [name, cache.get(key(name))]));
45
+ const missing = Object.keys(result).filter((name) => result[name] === undefined);
46
+ if (missing.length) {
47
+ // One request answers every missing question over the same state
48
+ const answer = await run.ask({
49
+ state,
50
+ questions: Object.fromEntries(missing.map((name) => [name, questions[name]]))
51
+ });
52
+ for (const name of missing) result[name] = answer.answers[name].noul;
53
+ await cache.put(missing.map((name) => [key(name), result[name]]));
54
+ }
55
+ return result;
56
+ }
57
+
58
+ try {
59
+ console.log(await screen("Junior data analyst, Denver, hybrid...", "Senior, statistics major, graduates May 2027..."));
60
+ } finally {
61
+ await run.close();
62
+ }
63
+ ```
64
+
65
+ The request's `model` is optional; the gate sets it.
66
+ A request that names a model other than `PINNED_MODEL` is refused.
67
+
68
+ ## What it guarantees
69
+
70
+ **A `429` slows everyone down together instead of hammering.**
71
+ When TypeSafe answers `429` or `529`, the gate pauses every caller on the account, across every tool and process on the machine, for the server's `Retry-After` delay (one second when none is given).
72
+ The request that got it waits out that pause and is retried, after the server's delay or with exponential backoff when none is given, up to `maxRetries` times (2 by default).
73
+ Only then does the ask fail, with a `ServiceError` whose `status` is `429`.
74
+
75
+ **It keeps under the account's rate limit before the service has to say so.**
76
+ Requests wait for room under 80% (`limits.share`) of the account's `requests_per_minute` and `tokens_per_second`, at most 4 (`limits.in_flight`) are open at once, and a request of 32,000 tokens or more goes one at a time.
77
+ Every TypeSafe key on the machine shares these limits, because TypeSafe counts them per account, not per key.
78
+
79
+ **The same question over the same state is answered once.**
80
+ The answer cache stores each answer's probability under a key you choose, such as the question name plus the state, and `cache.get` returns it on the next ask instead of sending the request again.
81
+ Answers last 30 days, and changing the cache's `scope`, the model or the never-send list starts fresh.
82
+ The cache stores only keyed hashes and numbers, never the state or question text.
83
+ A cache sees every answer stored before it was opened plus its own `put`s, so it removes repeats across later runs and within a run.
84
+ It does not see answers another process stores after it was opened, so two processes screening at the same time, or a long-lived cache opened earlier, can each pay for the same question; open a fresh cache for each batch, or screen from one process.
85
+ It also does not merge two identical asks started at the same moment, so check the cache before sending a batch.
86
+
87
+ **A daily spend ceiling per key.**
88
+ Every tool on the machine that uses the same key shares one daily ceiling, `spend.per_day_usd` (USD 0.20 by default, reset at UTC midnight), and each run has its own ceiling, `spend.per_run_usd` (USD 0.02 by default).
89
+ A request that does not fit under them is not sent, and the ask fails with a `SpendCapError`.
90
+ At the price 0.1.0 records for the pinned model, USD 0.042 per million input tokens with output free, the default daily ceiling covers about 4.7 million input tokens; raise it in the config for more.
91
+ The default run ceiling covers about 476,000, so a long screening job opens a run per batch or raises `per_run_usd` in the config too.
92
+
93
+ **Several questions over one state go in one call.**
94
+ A Jev request carries one `state` and any number of named `questions`, and the gate sends it as one request: one rate-limit slot, and the state's input tokens paid once however many questions it asks.
95
+ When the state is most of the request, as a job posting usually is, three questions in one call cost little more than one, and use a third of the requests.
96
+
97
+ **Nothing on the never-send list leaves the machine.**
98
+ Every request is checked, including each retry, and one containing a forbidden value is refused with a `RedactionError` rather than rewritten.
99
+ The built-in rules cover known secret formats and email addresses.
100
+ They also rewrite the value after an `authorization`, `proxy-authorization` or `auth` key, as a header, a JSON or YAML key or a parameter, in only two shapes.
101
+ One is an HTTP authentication scheme, such as `Basic`, `Bearer`, `Token`, `Digest`, `Negotiate` or `AWS4-HMAC-SHA256`, followed by a credential, redacted with its parameters to the end of its quotes or line.
102
+ A credential there is an auth-parameter list such as `username="u"`, a placeholder an earlier rule wrote such as `[token]`, or a run with no spaces that is not a plain word: it holds a digit, a capital after its first letter or one of `+ / = . _ ~ -`, or is a single character.
103
+ The other is a token-shaped value: a run of 16 or more characters with no spaces that holds a letter and either a digit, a `+` or a trailing `=`.
104
+ Any other value is kept, so "work authorization: F-1 OPT", "Work Authorization: US citizen", "OPT-STEM-Extension", "PermanentResident" and a posting's "Authorization: must be authorized to work" or "Authorization: Signature required on the I-9" reach the service unchanged.
105
+ Redact text with `jev.redactor.redact` before putting it in a request.
106
+
107
+ ## Using Jev through Vercel AI Gateway
108
+
109
+ > **Experimental and unpinned.**
110
+ > The `vercel-ai-gateway` provider sends requests through Vercel AI Gateway instead of to TypeSafe directly.
111
+ > It sends the floating model id `typesafe-ai/jev`, not `jev-1.13.0`, so the Jev version that answers can change without notice, and thresholds tuned on `jev-1.13.0` may not hold.
112
+ > `jev.config.pinned` is `false` for it.
113
+
114
+ Choose it by name, with `"provider": "vercel-ai-gateway"` in the config or `DECISION_GATE_PROVIDER=vercel-ai-gateway`, and export `AI_GATEWAY_API_KEY`.
115
+ The gate never picks a provider from whichever key variable happens to be set, since other tools export those keys for their own use.
116
+ The key is sent as a Bearer token only to `https://ai-gateway.vercel.sh/typesafe/v1/systemone`, with TypeSafe's request and answer shapes, so callers change nothing else.
117
+ A key belongs to one provider, so the gateway is never sent `TYPESAFE_API_KEY`, the top-level `key_file` or a caller's TypeSafe key; without a gateway key it refuses to send and names where one goes.
118
+
119
+ Spend is booked from the answer's reported input tokens at USD 0.042 per million input tokens, output free, in the gateway's own ledger with its own daily ceiling per key.
120
+ A `402` from the gateway fails the request with a `ServiceError` whose `status` is `402`.
121
+
122
+ Requests pass through Vercel, an added party on the data path.
123
+
124
+ The gateway gets its own rate window, `429` pause and in-flight requests, separate from a TypeSafe account's.
125
+ Its defaults are conservative guesses, not measurements: 60 requests a minute and 2 in flight.
126
+ Each ask is retried once, not twice, and a `429` without `Retry-After` pauses the gateway's callers for five seconds.
127
+ A `429` the gate cannot avoid still fails the ask after that pause and retry.
128
+
129
+ Answers cached through an unpinned provider expire within a day, because the model behind it can change without notice.
130
+
131
+ ## Opening the gate
132
+
133
+ `openJev({ tool, key, neverSend, spend, maxRetries, env, notice, fetch })` opens a caller.
134
+ `tool` is required: a short identifier such as `herdr-find` that tags the caller's spend records, not a different service.
135
+ `notice` receives one-line warnings and defaults to a no-op.
136
+ `spend` accepts `perRunUsd` and `perDayUsd`, defaulting to the config's USD 0.02 and USD 0.20.
137
+ The config's daily ceiling covers the key across every tool that uses it; a caller's `perDayUsd` can only lower what that tool spends, never add to the key's.
138
+ `maxRetries` is how many times a failed request is retried, 2 by default, or 1 through the gateway.
139
+ `fetch` replaces the network for tests; the destination is still checked.
140
+
141
+ The returned object holds `status()`, `config`, `redactor`, `remaining()`, `cache()` and `run()`.
142
+ `remaining()` resolves to what is left of today's ceiling for this tool on this key.
143
+
144
+ ## Key sources
145
+
146
+ Every key belongs to one provider and is only ever sent to that provider.
147
+ Key precedence is an explicit key for the selected provider, then that provider's key variable (`TYPESAFE_API_KEY`, or `AI_GATEWAY_API_KEY` for the gateway), then that provider's key file.
148
+ An explicit key names its provider and exactly one source: `key: { provider: "typesafe", file }`, `{ provider, env }` or `{ provider, value }`.
149
+ An explicit key for another provider is skipped, never sent.
150
+ TypeSafe's key file is the config's top-level `key_file`; the gateway's is `key_file` in a `"vercel-ai-gateway"` section, such as `"vercel-ai-gateway": { "key_file": "~/.config/decision-gate/gateway-key" }`, and that section holds nothing else.
151
+ No other credential location is guessed, and no provider falls back to another's key.
152
+ Key files must be regular, nonempty files with mode 600.
153
+ `jev.status()` returns `{ ok: true }` or `{ ok: false, reason }` without reading the contents of a key file; `missing: true` marks the case where the selected provider has no key source at all, and then `label` and `keyEnv` name the provider and its key variable.
154
+ A status check cannot establish whether the service will accept a credential.
155
+ The key is read when a request or remaining-budget lookup first needs its fingerprint, and it is never enumerable, logged or stored.
156
+
157
+ ## Configuration
158
+
159
+ The one config file is `$XDG_CONFIG_HOME/decision-gate/config.json`, normally `~/.config/decision-gate/config.json`.
160
+ `DECISION_GATE_CONFIG` overrides that location.
161
+ No file is required.
162
+
163
+ ```json
164
+ {
165
+ "provider": "typesafe",
166
+ "key_file": "~/.config/decision-gate/key",
167
+ "never_send_file": "~/.config/decision-gate/never-send.json",
168
+ "spend": { "per_run_usd": 0.02, "per_day_usd": 0.2 },
169
+ "limits": {
170
+ "requests_per_minute": 1200,
171
+ "tokens_per_second": 250000,
172
+ "share": 0.8,
173
+ "in_flight": 4
174
+ }
175
+ }
176
+ ```
177
+
178
+ `provider` is `typesafe` (the default) or `vercel-ai-gateway`; anything else is refused.
179
+ `DECISION_GATE_PROVIDER`, `DECISION_GATE_PER_RUN_USD`, `DECISION_GATE_PER_DAY_USD`, `DECISION_GATE_RPM`, `DECISION_GATE_TPS`, `DECISION_GATE_IN_FLIGHT` and `DECISION_GATE_NEVER_SEND_FILE` override the corresponding config values.
180
+ Relative paths in the file resolve beside it, and `~/` works.
181
+ Every tool reads the same limits and daily ceiling; an explicit caller per-run ceiling remains the caller's own.
182
+
183
+ ## Accounts and rate limits
184
+
185
+ TypeSafe counts rate limits per account, not per key.
186
+ A second key on the same account adds no capacity: measured on one account, small requests got about 47,000-56,000 tokens a second on one key and 46,000 combined on two, and large ones about 121,000 on one and 127,000 split across two.
187
+ A key per tool is for separate spend records and revocation, not for throughput.
188
+
189
+ A key does not reveal its account, so the gate assumes every TypeSafe key on this machine belongs to one account.
190
+ Every TypeSafe key shares that account's one rate window, one 429 pause and one set of in-flight requests, across every tool and process on the machine.
191
+
192
+ `limits` describes the account's ceiling:
193
+
194
+ - `requests_per_minute` and `tokens_per_second` are TypeSafe's published limits for the pinned model; the gate keeps to `share` of both.
195
+ - `in_flight` is how many requests the account may have open at once, 4 by default.
196
+
197
+ Those defaults are TypeSafe's, and the unsectioned keys apply to the `typesafe` provider only.
198
+ The gateway keeps its own defaults, 60 requests a minute and 2 in flight, whatever the unsectioned keys say.
199
+ A section named after a provider, such as `"limits": { "vercel-ai-gateway": { "in_flight": 1 } }`, applies only to that provider and wins over its defaults and, for `typesafe`, over the unsectioned keys; the `DECISION_GATE_*` variables win over all of them.
200
+
201
+ Only one large request, estimated at 32,000 tokens or more, is open at a time; that is fixed, not configured.
202
+
203
+ TypeSafe's `in_flight` default is measured: requests of about 6,100 tokens finished fastest with two to four in flight, and requests of about 50,000 tokens finished as fast one at a time as two or four at once.
204
+ A tool that sends requests in parallel should size its pool from `jev.config.limits.inFlight` instead of keeping its own setting, since the gate holds any extra requests until a slot frees.
205
+
206
+ ## Runs
207
+
208
+ ```js
209
+ const run = jev.run({ capUsd });
210
+ await run.ask(request, { signal });
211
+ run.summary();
212
+ await run.close();
213
+ ```
214
+
215
+ `capUsd` can lower the per-run ceiling, never raise it.
216
+ A request's `model` may be omitted or `PINNED_MODEL`, for either provider; the gate sends the provider's own model id, and refuses any other value.
217
+ Raw requests are checked, not silently rewritten: a forbidden value in any serialized field or its decoded JSON form prevents the request.
218
+ Callers that send user text redact it first with `jev.redactor.redact`, and `jev.redactor.check(body)` runs the same final check on a serialized request before anything is queued.
219
+ `jev.redactor.clean(text)` is true when one piece of text would pass that check as a string in a request: nothing forbidden survives in it and the built-in rules would leave it unchanged.
220
+ A caller batching many texts into one request can use it on each redacted text to hold back only the ones the check would refuse, instead of losing the whole request.
221
+ Built-in rules cover known secret formats, email addresses and authorization credentials in the two shapes above, not arbitrary long hashes or random-looking strings elsewhere, so file paths reach the service unchanged.
222
+ `privateKeyLines(lines)` maps each line of a private key piped in as separate lines to its redacted form, since such a key is only recognisable across lines.
223
+ A private optional never-send file adds user rules and forbidden patterns:
224
+
225
+ ```json
226
+ {
227
+ "rules": [["person", "(?i)Example Person", "[person]"]],
228
+ "forbidden": ["(?i)Example Person"]
229
+ }
230
+ ```
231
+
232
+ Await `close()` to replace the run's hold with its committed cost.
233
+ `summary()` reports reserved and billed USD and billed input tokens without storing text.
234
+ Its `ceiling` is `"day"` when what is left of today bounds the run and `"run"` otherwise.
235
+ Asks within one run may overlap: when the run's ceiling is full, an attempt waits for another to settle and is refused with a `SpendCapError` only when none is in flight.
236
+ `close()` waits for asks already made before it records the run's cost.
237
+ A request too long for the pinned model is refused with a `RequestSizeError` before anything is queued or sent.
238
+ The model's context length has two budgets: 65,536 tokens for the whole request (`MAX_INPUT_TOKENS`), and 32,768 tokens for the `state` plus the single longest question (`MAX_STATE_QUESTION_TOKENS`).
239
+ Both are checked with the same estimate of a quarter token per serialized byte that `estimateUsd` uses, so a state of about 100,000 ASCII characters with short questions fits.
240
+ The estimate is not exact, so a request close to either budget can still be rejected by the service.
241
+ Use `describeError` for a safe diagnostic instead of logging a transport exception or provider response body.
242
+ The errors it passes through are `ConfigError`, `ServiceError` (with the HTTP `status`), `SpendCapError`, `RedactionError`, `RequestSizeError` and `StateError`; none carries request text.
243
+
244
+ `PINNED_MODEL`, `MAX_INPUT_TOKENS`, `MAX_STATE_QUESTION_TOKENS`, `usdFor(tokens)` and `estimateUsd(bytes)` describe the pinned model's request limits and price, which the gate also books for the gateway.
245
+ `jev.config.provider`, `jev.config.model` and `jev.config.pinned` say which provider and model id a caller's requests go to, and whether that model is pinned.
246
+ `estimateUsd` uses the measured rate of about a quarter token per byte, for figures shown before a run; it is not a reservation.
247
+
248
+ ## Answer cache
249
+
250
+ ```js
251
+ const cache = jev.cache({ scope: { prompt: "line-v1", query }, enabled: true });
252
+ cache.get(key); // a probability, or undefined
253
+ await cache.put([[key, 0.93]]);
254
+ ```
255
+
256
+ The cache stores probabilities under caller-supplied string keys, and never the keys themselves.
257
+ Each key is stored as an HMAC under a private random key, so short keys such as source lines cannot be guessed from the stored hashes.
258
+ Entries are filed by the caller's `scope` together with the provider, model, endpoint and never-send list, so changing any of them starts a fresh file.
259
+ Entries hold only key hashes, probabilities and times.
260
+ They expire after 30 days, or a day for an unpinned provider; a file mostly made of expired or superseded rows is rewritten on its next write, and files are evicted to keep the cache within 50 MiB.
261
+ A cache that cannot be read or written warns once through `notice` and continues without caching, not without spend accounting.
262
+
263
+ ## Request ownership and persisted state
264
+
265
+ The official `@typesafe-ai/sdk` owns serialization, timeouts, retries and Retry-After parsing.
266
+ The gate explicitly sets its key, destination, model and logging level, so SDK environment defaults cannot redirect or log requests.
267
+ Before the SDK starts a request's first attempt, the gate waits for room under the run's ceiling and for one of the account's in-flight slots, so a queued request never uses up its attempt's 30-second timeout.
268
+ The slot is held until the request settles, across the SDK's retries.
269
+ Its fetch wrapper checks the exact body, reserves the attempt against the run's ceiling, counts it in the account's rate window and persists the reservation before every network attempt, including retries.
270
+ A refusal known not to be billed, such as a 429, hands its reservation to the retry, so the retry needs no new room.
271
+ A retry after an attempt that may have been billed reserves afresh without waiting; if it does not fit, that request fails as a service error, and only booked plus held spend reaching the ceiling stops a run at its ceiling.
272
+ A retry that waits out the rate window past its timeout is a timeout the SDK may retry.
273
+ Redirects are never followed.
274
+ A 429 or 529 records a pause for the whole account using the server's Retry-After delay, or a short fallback when absent, before SDK retry handling continues.
275
+ Each provider has one hard-coded destination, and no configuration can add another.
276
+ For tests only, `DECISION_GATE_ENDPOINT` may point at an HTTP URL on `127.0.0.1` or `::1`; any other host is refused.
277
+
278
+ State lives under `$XDG_STATE_HOME/decision-gate`, normally `~/.local/state/decision-gate`, and the cache under `$XDG_CACHE_HOME/decision-gate/answers`:
279
+
280
+ | Path | Holds |
281
+ | --- | --- |
282
+ | `cache-key` | The answer cache's private hash key |
283
+ | `spend/typesafe/<key-fingerprint>.jsonl` | The cost ledger: tool, time, hold and cost of each run, never text |
284
+ | `limits/typesafe/accounts/default.json` | The account's shared rate window, pause and in-flight requests |
285
+ | `spend/vercel-ai-gateway/<key-fingerprint>.jsonl` | The same ledger for gateway keys |
286
+ | `limits/vercel-ai-gateway/accounts/default.json` | The gateway's own rate window, pause and in-flight requests |
287
+
288
+ The fingerprint is the first 16 SHA-256 hex characters, never the key.
289
+ Rate requests use a rolling minute window; tokens use a rolling second window, counting each request at its size in bytes, up to the request limit.
290
+ Measured usage is about a quarter token per byte, so this overcounts; the spend ceiling, not the limiter, is the guaranteed bound.
291
+ The TypeSafe defaults enforce 960 requests a minute and 200,000 reserved tokens a second.
292
+ An in-flight request whose process stopped without releasing it is dropped once that process is gone, or after two minutes without an attempt.
293
+ If the limiter's directory cannot be written, it warns once per limiter and uses in-process limits.
294
+ This does not disable the daily ceiling: unwritable spend state still refuses paid requests.
295
+
296
+ The daily ceiling counts every tool's records on the key, so tools sharing a key cannot together pass it.
297
+ A run reserves its allowance under a short filesystem lock so concurrent runs cannot allocate the same daily balance.
298
+ The daily boundary is UTC midnight.
299
+ Unused holds expire after ten minutes; booked attempts remain charged after a crash.
300
+ A resumed stale hold re-checks capacity before further spending.
301
+ The ledger keeps only today's and yesterday's records once older or superseded rows outnumber them.
302
+
303
+ Each read-modify-write of local state takes a lock directory for microseconds, never across a network call or sleep.
304
+ The lock is published with its owner already inside, so a crash cannot leave an ownerless lock.
305
+ A lock whose owner process is gone, or that is older than 30 seconds, is moved aside under a name tied to that owner, so two processes reclaiming the same stale lock cannot remove a newer one.
306
+
307
+ The reservation uses the pinned model's full documented request limit of 65,536 tokens (`MAX_INPUT_TOKENS`), not an empirical bytes-per-token ratio.
308
+ Successful responses replace the reservation with reported input usage.
309
+ An uncertain attempt remains booked at its reservation.
310
+ This can refuse a very small allowance even when a displayed estimate is lower.
311
+
312
+ ## Providers and dependency review
313
+
314
+ Native TypeSafe and, experimentally, Vercel AI Gateway are implemented; both speak TypeSafe's API through the same SDK responder.
315
+ A provider keeps its name, key variable, model id and whether it is pinned, price, endpoint, limit defaults and retry settings together, and its state is filed under its name, so adding one needs no migration.
316
+ Another provider needs a known price, where zero counts for a local model, and comparable typed probabilities before it can support these ceilings and callers' thresholds.
317
+
318
+ The pinned `@typesafe-ai/sdk` version is 0.6.0, with no runtime dependencies or install hooks.
319
+ Its published ESM entry point was inspected for credentials, logging, HTTP destinations, retries and filesystem or execution side effects.
320
+ SDK logging is explicitly off because debug logging can include request bodies.
321
+ All local state and key-file access belong to decision-gate.
package/lib/budget.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Spend accounting adapted from herdr-find 4736dd5 (Apache-2.0)
2
+ import { SpendCapError } from "./errors.mjs";
3
+
4
+ export const usdAt = (price, tokens) => (tokens * price.usd_per_million_input_tokens) / 1e6;
5
+
6
+ // A spend cap enforced before every send: an attempt reserves its worst case first and is refused
7
+ // if the reservation would pass the cap. A response books its billed tokens; an attempt that may
8
+ // have reached the service without a usable answer books its reservation, since it may be billed.
9
+ export function createSpendBudget({ capUsd, price, spentUsd = 0, onChange = () => {} }) {
10
+ if (!(Number.isFinite(capUsd) && capUsd >= 0)) throw new TypeError("a nonnegative spend cap in USD is required");
11
+ let booked = spentUsd;
12
+ let reserved = 0;
13
+ let open = 0;
14
+ let billedTokens = 0;
15
+ let unknownAttempts = 0;
16
+ let waiters = [];
17
+ const wake = () => { for (const resolve of waiters) resolve(); waiters = []; };
18
+ const reserve = (tokens) => {
19
+ const usd = usdAt(price, tokens);
20
+ if (booked + reserved + usd > capUsd) throw new SpendCapError("spend cap reached");
21
+ reserved += usd;
22
+ open += 1;
23
+ onChange(booked + reserved);
24
+ let active = true;
25
+ const finish = () => { active = false; reserved -= usd; open -= 1; };
26
+ return {
27
+ settle(billed) {
28
+ if (!active) return;
29
+ finish();
30
+ if (Number.isInteger(billed) && billed >= 0) {
31
+ booked += usdAt(price, billed);
32
+ billedTokens += billed;
33
+ } else {
34
+ booked += usd;
35
+ unknownAttempts += 1;
36
+ }
37
+ onChange(booked + reserved);
38
+ wake();
39
+ },
40
+ release() {
41
+ if (!active) return;
42
+ finish();
43
+ onChange(booked + reserved);
44
+ wake();
45
+ }
46
+ };
47
+ };
48
+ return {
49
+ capUsd,
50
+ reserve,
51
+ // Attempts in flight usually settle far below their worst case, so a full cap waits for one
52
+ // of them before refusing; with nothing in flight the refusal is final
53
+ async acquire(tokens, signal) {
54
+ for (;;) {
55
+ signal?.throwIfAborted();
56
+ try { return reserve(tokens); }
57
+ catch (error) { if (!(error instanceof SpendCapError) || !open) throw error; }
58
+ await new Promise((resolve, reject) => {
59
+ const abort = () => reject(signal.reason);
60
+ signal?.addEventListener("abort", abort, { once: true });
61
+ waiters.push(() => { signal?.removeEventListener("abort", abort); resolve(); });
62
+ });
63
+ }
64
+ },
65
+ // What is committed so far, counting requests still in flight at their reservation
66
+ committedUsd: () => booked + reserved,
67
+ summary() {
68
+ return { cap_usd: capUsd, committed_usd: booked + reserved, billed_input_tokens: billedTokens, attempts_booked_at_reservation: unknownAttempts };
69
+ }
70
+ };
71
+ }