@tokenrein/sdk 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +32 -2
- package/dist/clients.d.ts +57 -0
- package/dist/clients.js +357 -0
- package/dist/direct.d.ts +52 -0
- package/dist/direct.js +229 -0
- package/dist/env.d.ts +5 -0
- package/dist/env.js +17 -0
- package/dist/errors.d.ts +51 -0
- package/dist/errors.js +90 -0
- package/dist/guard.d.ts +16 -0
- package/dist/guard.js +38 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/run.d.ts +52 -0
- package/dist/run.js +93 -0
- package/package.json +54 -4
package/dist/direct.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct mode: the wrapped client calls its provider itself; Tokenrein gets usage only.
|
|
3
|
+
*
|
|
4
|
+
* Before each call the SDK asks Tokenrein whether the run may continue (`POST /v1/check`);
|
|
5
|
+
* after each response it reads the token usage (also from streams) and reports it in the
|
|
6
|
+
* background (`POST /v1/usage`). Prompts and responses never leave the process. Problems
|
|
7
|
+
* reaching Tokenrein never break a call: checks fail open and reports are dropped after retries.
|
|
8
|
+
*/
|
|
9
|
+
import { TokenreinError } from './errors.js';
|
|
10
|
+
/** Name the provider from the client's base URL (OpenAI-compatible clients reach many). */
|
|
11
|
+
export declare function providerFor(baseURL: string, fallback: string): string;
|
|
12
|
+
export interface TokenUsage {
|
|
13
|
+
input_tokens: number;
|
|
14
|
+
output_tokens: number;
|
|
15
|
+
cache_read_tokens: number;
|
|
16
|
+
cache_write_tokens: number;
|
|
17
|
+
}
|
|
18
|
+
/** Token counts from an OpenAI- or Anthropic-shaped object with a `usage` field. */
|
|
19
|
+
export declare function usageFrom(obj: unknown): TokenUsage | undefined;
|
|
20
|
+
/** Accumulates usage from server-sent events. */
|
|
21
|
+
export declare class StreamUsage {
|
|
22
|
+
#private;
|
|
23
|
+
model: string | undefined;
|
|
24
|
+
usage: TokenUsage | undefined;
|
|
25
|
+
feed(text: string): void;
|
|
26
|
+
}
|
|
27
|
+
export interface Verdict {
|
|
28
|
+
allowed: boolean;
|
|
29
|
+
reason?: string;
|
|
30
|
+
scope?: string;
|
|
31
|
+
run_state?: string | null;
|
|
32
|
+
run_id?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Talks to Tokenrein: budget checks and batched usage reports. */
|
|
35
|
+
export declare class Reporter {
|
|
36
|
+
#private;
|
|
37
|
+
readonly baseURL: string;
|
|
38
|
+
readonly apiKey: string;
|
|
39
|
+
readonly checkTimeoutMs: number;
|
|
40
|
+
/** Enforced limits that recent reports said are used up: label -> time noted. */
|
|
41
|
+
readonly over: Map<string, number>;
|
|
42
|
+
constructor(baseURL: string, apiKey: string, checkTimeoutMs?: number);
|
|
43
|
+
/** Fails open when Tokenrein can't be reached. */
|
|
44
|
+
check(body: Record<string, unknown>): Promise<Verdict | undefined>;
|
|
45
|
+
report(event: Record<string, unknown>): void;
|
|
46
|
+
/** Send queued reports now. */
|
|
47
|
+
flush(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export declare function reporterFor(baseURL: string, apiKey: string): Reporter;
|
|
50
|
+
/** Send every queued usage report now (call before a short-lived process exits). */
|
|
51
|
+
export declare function flush(): Promise<void>;
|
|
52
|
+
export declare function verdictError(v: Verdict, runId: string | undefined): TokenreinError;
|
package/dist/direct.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct mode: the wrapped client calls its provider itself; Tokenrein gets usage only.
|
|
3
|
+
*
|
|
4
|
+
* Before each call the SDK asks Tokenrein whether the run may continue (`POST /v1/check`);
|
|
5
|
+
* after each response it reads the token usage (also from streams) and reports it in the
|
|
6
|
+
* background (`POST /v1/usage`). Prompts and responses never leave the process. Problems
|
|
7
|
+
* reaching Tokenrein never break a call: checks fail open and reports are dropped after retries.
|
|
8
|
+
*/
|
|
9
|
+
import { BUDGET_EXCEEDED, BudgetExceeded, RUN_KILLED, RUN_PAUSED, RunKilled, RunPaused, TokenreinError } from './errors.js';
|
|
10
|
+
const HOST_PROVIDERS = {
|
|
11
|
+
'api.openai.com': 'openai',
|
|
12
|
+
'api.anthropic.com': 'anthropic',
|
|
13
|
+
'generativelanguage.googleapis.com': 'google',
|
|
14
|
+
'api.groq.com': 'groq',
|
|
15
|
+
'api.mistral.ai': 'mistral',
|
|
16
|
+
'api.deepseek.com': 'deepseek',
|
|
17
|
+
'api.x.ai': 'xai',
|
|
18
|
+
'openrouter.ai': 'openrouter',
|
|
19
|
+
'api.together.xyz': 'together',
|
|
20
|
+
'api.fireworks.ai': 'fireworks',
|
|
21
|
+
'api.cohere.ai': 'cohere',
|
|
22
|
+
'api.cohere.com': 'cohere',
|
|
23
|
+
};
|
|
24
|
+
/** Name the provider from the client's base URL (OpenAI-compatible clients reach many). */
|
|
25
|
+
export function providerFor(baseURL, fallback) {
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(baseURL);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
const host = url.hostname.toLowerCase();
|
|
34
|
+
if (HOST_PROVIDERS[host])
|
|
35
|
+
return HOST_PROVIDERS[host];
|
|
36
|
+
if (host.endsWith('.openai.azure.com'))
|
|
37
|
+
return 'openai';
|
|
38
|
+
if (['localhost', '127.0.0.1', '[::1]', '::1'].includes(host)) {
|
|
39
|
+
// Well-known local model servers are free; any other local address may be a gateway to
|
|
40
|
+
// paid models, so it is priced by model name.
|
|
41
|
+
return { '11434': 'ollama', '1234': 'lmstudio', '8000': 'vllm' }[url.port] ?? fallback;
|
|
42
|
+
}
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
46
|
+
/** Token counts from an OpenAI- or Anthropic-shaped object with a `usage` field. */
|
|
47
|
+
export function usageFrom(obj) {
|
|
48
|
+
const u = obj?.usage;
|
|
49
|
+
if (!u || typeof u !== 'object')
|
|
50
|
+
return undefined;
|
|
51
|
+
const nested = (a, b) => num(u[a]?.[b]);
|
|
52
|
+
if ('prompt_tokens' in u) {
|
|
53
|
+
const cached = nested('prompt_tokens_details', 'cached_tokens');
|
|
54
|
+
return { input_tokens: Math.max(0, num(u.prompt_tokens) - cached), output_tokens: num(u.completion_tokens), cache_read_tokens: cached, cache_write_tokens: 0 };
|
|
55
|
+
}
|
|
56
|
+
if ('input_tokens_details' in u || 'output_tokens_details' in u) {
|
|
57
|
+
const cached = nested('input_tokens_details', 'cached_tokens');
|
|
58
|
+
return { input_tokens: Math.max(0, num(u.input_tokens) - cached), output_tokens: num(u.output_tokens), cache_read_tokens: cached, cache_write_tokens: 0 };
|
|
59
|
+
}
|
|
60
|
+
if ('input_tokens' in u || 'output_tokens' in u) {
|
|
61
|
+
return {
|
|
62
|
+
input_tokens: num(u.input_tokens),
|
|
63
|
+
output_tokens: num(u.output_tokens),
|
|
64
|
+
cache_read_tokens: num(u.cache_read_input_tokens),
|
|
65
|
+
cache_write_tokens: num(u.cache_creation_input_tokens),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/** Accumulates usage from server-sent events. */
|
|
71
|
+
export class StreamUsage {
|
|
72
|
+
model;
|
|
73
|
+
usage;
|
|
74
|
+
#buf = '';
|
|
75
|
+
feed(text) {
|
|
76
|
+
this.#buf += text;
|
|
77
|
+
let i;
|
|
78
|
+
while ((i = this.#buf.indexOf('\n')) >= 0) {
|
|
79
|
+
const line = this.#buf.slice(0, i).trim();
|
|
80
|
+
this.#buf = this.#buf.slice(i + 1);
|
|
81
|
+
this.#line(line);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
#line(line) {
|
|
85
|
+
if (!line.startsWith('data:'))
|
|
86
|
+
return;
|
|
87
|
+
const data = line.slice(5).trim();
|
|
88
|
+
if (!data || data === '[DONE]')
|
|
89
|
+
return;
|
|
90
|
+
let ev;
|
|
91
|
+
try {
|
|
92
|
+
ev = JSON.parse(data);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!ev || typeof ev !== 'object')
|
|
98
|
+
return;
|
|
99
|
+
if (ev.type === 'message_start') {
|
|
100
|
+
this.model = ev.message?.model ?? this.model;
|
|
101
|
+
this.usage = usageFrom(ev.message) ?? this.usage;
|
|
102
|
+
}
|
|
103
|
+
else if (ev.type === 'message_delta') {
|
|
104
|
+
if (typeof ev.usage?.output_tokens === 'number') {
|
|
105
|
+
this.usage = { input_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, ...this.usage, output_tokens: ev.usage.output_tokens };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (ev.type === 'response.completed') {
|
|
109
|
+
this.model = ev.response?.model ?? this.model;
|
|
110
|
+
this.usage = usageFrom(ev.response) ?? this.usage;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
this.model = ev.model ?? this.model;
|
|
114
|
+
this.usage = usageFrom(ev) ?? this.usage;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Talks to Tokenrein: budget checks and batched usage reports. */
|
|
119
|
+
export class Reporter {
|
|
120
|
+
baseURL;
|
|
121
|
+
apiKey;
|
|
122
|
+
checkTimeoutMs;
|
|
123
|
+
/** Enforced limits that recent reports said are used up: label -> time noted. */
|
|
124
|
+
over = new Map();
|
|
125
|
+
#queue = [];
|
|
126
|
+
#timer;
|
|
127
|
+
#inflight = Promise.resolve();
|
|
128
|
+
constructor(baseURL, apiKey, checkTimeoutMs = 2000) {
|
|
129
|
+
this.baseURL = baseURL;
|
|
130
|
+
this.apiKey = apiKey;
|
|
131
|
+
this.checkTimeoutMs = checkTimeoutMs;
|
|
132
|
+
}
|
|
133
|
+
async #post(path, body, timeoutMs) {
|
|
134
|
+
return globalThis.fetch(this.baseURL + path, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, 'content-type': 'application/json' },
|
|
137
|
+
body: JSON.stringify(body),
|
|
138
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Fails open when Tokenrein can't be reached. */
|
|
142
|
+
async check(body) {
|
|
143
|
+
let res;
|
|
144
|
+
try {
|
|
145
|
+
res = await this.#post('/v1/check', body, this.checkTimeoutMs);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (res.status === 401) {
|
|
151
|
+
throw new TokenreinError('Tokenrein rejected the API key (TOKENREIN_API_KEY)', { errorType: 'tokenrein_unauthorized', status: 401 });
|
|
152
|
+
}
|
|
153
|
+
if (!res.ok)
|
|
154
|
+
return undefined;
|
|
155
|
+
try {
|
|
156
|
+
return (await res.json());
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
report(event) {
|
|
163
|
+
if (this.#queue.length >= 10_000)
|
|
164
|
+
return;
|
|
165
|
+
this.#queue.push(event);
|
|
166
|
+
if (!this.#timer) {
|
|
167
|
+
this.#timer = setTimeout(() => {
|
|
168
|
+
this.#timer = undefined;
|
|
169
|
+
this.#inflight = this.#inflight.then(() => this.#send());
|
|
170
|
+
}, 200);
|
|
171
|
+
this.#timer.unref?.();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Send queued reports now. */
|
|
175
|
+
async flush() {
|
|
176
|
+
if (this.#timer) {
|
|
177
|
+
clearTimeout(this.#timer);
|
|
178
|
+
this.#timer = undefined;
|
|
179
|
+
this.#inflight = this.#inflight.then(() => this.#send());
|
|
180
|
+
}
|
|
181
|
+
await this.#inflight;
|
|
182
|
+
}
|
|
183
|
+
async #send() {
|
|
184
|
+
while (this.#queue.length) {
|
|
185
|
+
const batch = this.#queue.splice(0, 100);
|
|
186
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
187
|
+
try {
|
|
188
|
+
const res = await this.#post('/v1/usage', { events: batch }, 10_000);
|
|
189
|
+
if (res.ok) {
|
|
190
|
+
const out = (await res.json());
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
for (const label of out.over ?? [])
|
|
193
|
+
this.over.set(label, now);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (res.status < 500)
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// retry below
|
|
201
|
+
}
|
|
202
|
+
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const reporters = new Map();
|
|
208
|
+
export function reporterFor(baseURL, apiKey) {
|
|
209
|
+
const key = `${baseURL}\u0000${apiKey}`;
|
|
210
|
+
let r = reporters.get(key);
|
|
211
|
+
if (!r) {
|
|
212
|
+
r = new Reporter(baseURL, apiKey);
|
|
213
|
+
reporters.set(key, r);
|
|
214
|
+
}
|
|
215
|
+
return r;
|
|
216
|
+
}
|
|
217
|
+
/** Send every queued usage report now (call before a short-lived process exits). */
|
|
218
|
+
export async function flush() {
|
|
219
|
+
await Promise.all([...reporters.values()].map((r) => r.flush()));
|
|
220
|
+
}
|
|
221
|
+
export function verdictError(v, runId) {
|
|
222
|
+
const message = v.reason ?? 'Tokenrein refused the call';
|
|
223
|
+
const id = v.run_id ?? runId;
|
|
224
|
+
if (v.run_state === 'killed')
|
|
225
|
+
return new RunKilled(message, { runId: id, errorType: RUN_KILLED });
|
|
226
|
+
if (v.run_state === 'paused')
|
|
227
|
+
return new RunPaused(message, { runId: id, errorType: RUN_PAUSED });
|
|
228
|
+
return new BudgetExceeded(message, { runId: id, errorType: BUDGET_EXCEEDED });
|
|
229
|
+
}
|
package/dist/env.d.ts
ADDED
package/dist/env.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const warned = new Set();
|
|
2
|
+
/**
|
|
3
|
+
* `TOKENREIN_<name>` from the environment, or the name used before the product was renamed
|
|
4
|
+
* (read with a deprecation warning until v1.1).
|
|
5
|
+
*/
|
|
6
|
+
export function envVar(name) {
|
|
7
|
+
const value = process.env[`TOKENREIN_${name}`];
|
|
8
|
+
if (value)
|
|
9
|
+
return value;
|
|
10
|
+
const old = `TOKENLENS_${name}`; // pre-rename name
|
|
11
|
+
const legacy = process.env[old];
|
|
12
|
+
if (legacy && !warned.has(old)) {
|
|
13
|
+
warned.add(old);
|
|
14
|
+
process.emitWarning(`${old} is deprecated; set TOKENREIN_${name}`, 'DeprecationWarning');
|
|
15
|
+
}
|
|
16
|
+
return legacy || undefined;
|
|
17
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Errors raised when the Tokenrein proxy (or the local fallback cap) stops a call. */
|
|
2
|
+
export declare const BUDGET_EXCEEDED = "tokenrein_budget_exceeded";
|
|
3
|
+
export declare const RUN_PAUSED = "tokenrein_run_paused";
|
|
4
|
+
export declare const RUN_KILLED = "tokenrein_run_killed";
|
|
5
|
+
export declare const LOCAL_CAP_EXCEEDED = "tokenrein_local_cap_exceeded";
|
|
6
|
+
/**
|
|
7
|
+
* Base class for every Tokenrein error.
|
|
8
|
+
*
|
|
9
|
+
* Errors from the proxy are the provider SDK's own error objects (e.g. `OpenAI.APIError`,
|
|
10
|
+
* `Anthropic.PermissionDeniedError`) re-labelled as Tokenrein errors, so both
|
|
11
|
+
* `err instanceof BudgetExceeded` and existing `err instanceof OpenAI.APIError` checks work.
|
|
12
|
+
*/
|
|
13
|
+
export declare class TokenreinError extends Error {
|
|
14
|
+
/** The run the proxy evaluated (response header `x-tr-run-id`), if known. */
|
|
15
|
+
runId: string | undefined;
|
|
16
|
+
/** The proxy's error type, e.g. `tokenrein_budget_exceeded`. */
|
|
17
|
+
errorType: string;
|
|
18
|
+
/** HTTP status from the proxy; `undefined` for local errors. */
|
|
19
|
+
status: number | undefined;
|
|
20
|
+
constructor(message: string, opts: {
|
|
21
|
+
runId?: string;
|
|
22
|
+
errorType: string;
|
|
23
|
+
status?: number;
|
|
24
|
+
});
|
|
25
|
+
static [Symbol.hasInstance](value: unknown): boolean;
|
|
26
|
+
}
|
|
27
|
+
/** HTTP 402: the call would put the run (or an ancestor, agent, project or org) over budget. */
|
|
28
|
+
export declare class BudgetExceeded extends TokenreinError {
|
|
29
|
+
}
|
|
30
|
+
/** HTTP 403: the run or one of its ancestors was paused or killed. */
|
|
31
|
+
export declare class RunStopped extends TokenreinError {
|
|
32
|
+
}
|
|
33
|
+
/** HTTP 403 `tokenrein_run_paused`: resume the run from the dashboard or admin API. */
|
|
34
|
+
export declare class RunPaused extends RunStopped {
|
|
35
|
+
}
|
|
36
|
+
/** HTTP 403 `tokenrein_run_killed`: the run is blocked permanently. */
|
|
37
|
+
export declare class RunKilled extends RunStopped {
|
|
38
|
+
}
|
|
39
|
+
/** The in-process `maxCalls` cap of a run was reached; the request was not sent. */
|
|
40
|
+
export declare class LocalCapExceeded extends BudgetExceeded {
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `[type, message]` from an OpenAI-shaped `{"error": {...}}` or Anthropic-shaped
|
|
44
|
+
* `{"type": "error", "error": {...}}` body.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseError(body: unknown): [string | undefined, string | undefined];
|
|
47
|
+
/**
|
|
48
|
+
* Re-label the SDK's error for a proxy response as a Tokenrein error, in place.
|
|
49
|
+
* Returns `sdkError` unchanged when the response is not a Tokenrein 402/403.
|
|
50
|
+
*/
|
|
51
|
+
export declare function relabel<E extends Error>(sdkError: E, status: number, body: unknown, runId: string | undefined): E;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Errors raised when the Tokenrein proxy (or the local fallback cap) stops a call. */
|
|
2
|
+
export const BUDGET_EXCEEDED = 'tokenrein_budget_exceeded';
|
|
3
|
+
export const RUN_PAUSED = 'tokenrein_run_paused';
|
|
4
|
+
export const RUN_KILLED = 'tokenrein_run_killed';
|
|
5
|
+
export const LOCAL_CAP_EXCEEDED = 'tokenrein_local_cap_exceeded';
|
|
6
|
+
/** Set on SDK errors that Tokenrein re-labels; points at the Tokenrein class they belong to. */
|
|
7
|
+
const KIND = Symbol.for('tokenrein.errorKind');
|
|
8
|
+
/**
|
|
9
|
+
* Base class for every Tokenrein error.
|
|
10
|
+
*
|
|
11
|
+
* Errors from the proxy are the provider SDK's own error objects (e.g. `OpenAI.APIError`,
|
|
12
|
+
* `Anthropic.PermissionDeniedError`) re-labelled as Tokenrein errors, so both
|
|
13
|
+
* `err instanceof BudgetExceeded` and existing `err instanceof OpenAI.APIError` checks work.
|
|
14
|
+
*/
|
|
15
|
+
export class TokenreinError extends Error {
|
|
16
|
+
/** The run the proxy evaluated (response header `x-tr-run-id`), if known. */
|
|
17
|
+
runId;
|
|
18
|
+
/** The proxy's error type, e.g. `tokenrein_budget_exceeded`. */
|
|
19
|
+
errorType;
|
|
20
|
+
/** HTTP status from the proxy; `undefined` for local errors. */
|
|
21
|
+
status;
|
|
22
|
+
constructor(message, opts) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = new.target.name;
|
|
25
|
+
this.runId = opts.runId;
|
|
26
|
+
this.errorType = opts.errorType;
|
|
27
|
+
this.status = opts.status;
|
|
28
|
+
}
|
|
29
|
+
static [Symbol.hasInstance](value) {
|
|
30
|
+
if (Function.prototype[Symbol.hasInstance].call(this, value))
|
|
31
|
+
return true;
|
|
32
|
+
const kind = value?.[KIND];
|
|
33
|
+
return typeof kind === 'function' && (kind === this || kind.prototype instanceof this);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** HTTP 402: the call would put the run (or an ancestor, agent, project or org) over budget. */
|
|
37
|
+
export class BudgetExceeded extends TokenreinError {
|
|
38
|
+
}
|
|
39
|
+
/** HTTP 403: the run or one of its ancestors was paused or killed. */
|
|
40
|
+
export class RunStopped extends TokenreinError {
|
|
41
|
+
}
|
|
42
|
+
/** HTTP 403 `tokenrein_run_paused`: resume the run from the dashboard or admin API. */
|
|
43
|
+
export class RunPaused extends RunStopped {
|
|
44
|
+
}
|
|
45
|
+
/** HTTP 403 `tokenrein_run_killed`: the run is blocked permanently. */
|
|
46
|
+
export class RunKilled extends RunStopped {
|
|
47
|
+
}
|
|
48
|
+
/** The in-process `maxCalls` cap of a run was reached; the request was not sent. */
|
|
49
|
+
export class LocalCapExceeded extends BudgetExceeded {
|
|
50
|
+
}
|
|
51
|
+
const BY_STATUS_AND_TYPE = {
|
|
52
|
+
[`402 ${BUDGET_EXCEEDED}`]: BudgetExceeded,
|
|
53
|
+
[`403 ${RUN_PAUSED}`]: RunPaused,
|
|
54
|
+
[`403 ${RUN_KILLED}`]: RunKilled,
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* `[type, message]` from an OpenAI-shaped `{"error": {...}}` or Anthropic-shaped
|
|
58
|
+
* `{"type": "error", "error": {...}}` body.
|
|
59
|
+
*/
|
|
60
|
+
export function parseError(body) {
|
|
61
|
+
const err = body?.error;
|
|
62
|
+
if (!err || typeof err !== 'object')
|
|
63
|
+
return [undefined, undefined];
|
|
64
|
+
const { type, message } = err;
|
|
65
|
+
return [typeof type === 'string' ? type : undefined, typeof message === 'string' ? message : undefined];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Re-label the SDK's error for a proxy response as a Tokenrein error, in place.
|
|
69
|
+
* Returns `sdkError` unchanged when the response is not a Tokenrein 402/403.
|
|
70
|
+
*/
|
|
71
|
+
export function relabel(sdkError, status, body, runId) {
|
|
72
|
+
const [type, message] = parseError(body);
|
|
73
|
+
const cls = type && BY_STATUS_AND_TYPE[`${status} ${type}`];
|
|
74
|
+
if (!cls || !type)
|
|
75
|
+
return sdkError;
|
|
76
|
+
const firstLine = `${sdkError.name}: ${sdkError.message}`;
|
|
77
|
+
Object.defineProperty(sdkError, KIND, { value: cls });
|
|
78
|
+
const fields = {
|
|
79
|
+
name: cls.name,
|
|
80
|
+
message: message ?? sdkError.message,
|
|
81
|
+
runId,
|
|
82
|
+
errorType: type,
|
|
83
|
+
status,
|
|
84
|
+
};
|
|
85
|
+
Object.assign(sdkError, fields);
|
|
86
|
+
if (typeof sdkError.stack === 'string' && sdkError.stack.startsWith(firstLine)) {
|
|
87
|
+
sdkError.stack = `${sdkError.name}: ${sdkError.message}` + sdkError.stack.slice(firstLine.length);
|
|
88
|
+
}
|
|
89
|
+
return sdkError;
|
|
90
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type GuardOptions } from './clients.js';
|
|
2
|
+
export { flush } from './direct.js';
|
|
3
|
+
export { run, currentRun, Run, type RunOptions } from './run.js';
|
|
4
|
+
export type { GuardOptions } from './clients.js';
|
|
5
|
+
/**
|
|
6
|
+
* Return a copy of an `OpenAI` client guarded by Tokenrein. Works with any provider the
|
|
7
|
+
* `openai` SDK reaches through `baseURL` (OpenAI, Azure, Gemini, Groq, Mistral, DeepSeek, xAI,
|
|
8
|
+
* OpenRouter, Together, Ollama, vLLM, ...).
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const client = guard.openai(new OpenAI(), { agent: 'support-bot' });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare function openai<T>(client: T, options?: GuardOptions): T;
|
|
15
|
+
/** Return a copy of an `Anthropic` client guarded by Tokenrein. Options as {@link openai}. */
|
|
16
|
+
export declare function anthropic<T>(client: T, options?: GuardOptions): T;
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { envVar } from './env.js';
|
|
2
|
+
import { resolveApiKey, resolveBaseURL, wrap, wrapDirect } from './clients.js';
|
|
3
|
+
export { flush } from './direct.js';
|
|
4
|
+
function direct(options) {
|
|
5
|
+
const mode = options.mode ?? envVar('MODE') ?? 'direct';
|
|
6
|
+
if (mode !== 'direct' && mode !== 'proxy')
|
|
7
|
+
throw new TypeError("mode must be 'direct' or 'proxy'");
|
|
8
|
+
return mode === 'direct';
|
|
9
|
+
}
|
|
10
|
+
function directOptions(options, defaultProvider) {
|
|
11
|
+
return {
|
|
12
|
+
tokenreinURL: resolveBaseURL(options.baseURL),
|
|
13
|
+
apiKey: resolveApiKey(options.apiKey),
|
|
14
|
+
agent: options.agent,
|
|
15
|
+
user: options.user,
|
|
16
|
+
defaultProvider,
|
|
17
|
+
check: options.check ?? true,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export { run, currentRun, Run } from './run.js';
|
|
21
|
+
/**
|
|
22
|
+
* Return a copy of an `OpenAI` client guarded by Tokenrein. Works with any provider the
|
|
23
|
+
* `openai` SDK reaches through `baseURL` (OpenAI, Azure, Gemini, Groq, Mistral, DeepSeek, xAI,
|
|
24
|
+
* OpenRouter, Together, Ollama, vLLM, ...).
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* const client = guard.openai(new OpenAI(), { agent: 'support-bot' });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function openai(client, options = {}) {
|
|
31
|
+
return direct(options) ? wrapDirect(client, directOptions(options, 'openai'), 'openai') : wrap(client, options, 'openai');
|
|
32
|
+
}
|
|
33
|
+
/** Return a copy of an `Anthropic` client guarded by Tokenrein. Options as {@link openai}. */
|
|
34
|
+
export function anthropic(client, options = {}) {
|
|
35
|
+
return direct(options)
|
|
36
|
+
? wrapDirect(client, directOptions(options, 'anthropic'), 'anthropic')
|
|
37
|
+
: wrap(client, options, 'anthropic');
|
|
38
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * as guard from './guard.js';
|
|
2
|
+
export { flush } from './direct.js';
|
|
3
|
+
export { Run, currentRun, type RunOptions } from './run.js';
|
|
4
|
+
export type { GuardOptions } from './clients.js';
|
|
5
|
+
export { TokenreinError, BudgetExceeded, LocalCapExceeded, RunStopped, RunPaused, RunKilled, BUDGET_EXCEEDED, RUN_PAUSED, RUN_KILLED, LOCAL_CAP_EXCEEDED, } from './errors.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export * as guard from './guard.js';
|
|
2
|
+
export { flush } from './direct.js';
|
|
3
|
+
export { Run, currentRun } from './run.js';
|
|
4
|
+
export { TokenreinError, BudgetExceeded, LocalCapExceeded, RunStopped, RunPaused, RunKilled, BUDGET_EXCEEDED, RUN_PAUSED, RUN_KILLED, LOCAL_CAP_EXCEEDED, } from './errors.js';
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Run scopes: which run (and parent run) the current async context is executing in. */
|
|
2
|
+
export declare function validateId(value: string, what: string): string;
|
|
3
|
+
/** Users may be emails. */
|
|
4
|
+
export declare function validateUser(value: string): string;
|
|
5
|
+
export declare function newRunId(): string;
|
|
6
|
+
export interface RunOptions {
|
|
7
|
+
/** Run id (1-128 of `A-Z a-z 0-9 . _ -`). Generated when omitted. */
|
|
8
|
+
runId?: string;
|
|
9
|
+
/** Budget for this run (`x-tr-run-budget-usd`), fixed by the proxy on first use. */
|
|
10
|
+
budgetUsd?: number;
|
|
11
|
+
/** Agent name for clients wrapped without `agent`. Not inherited by child runs. */
|
|
12
|
+
agent?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Person or service the run works for (`x-tr-user`), for spend reports by user. Inherited by
|
|
15
|
+
* child runs; a client's own `user` takes precedence.
|
|
16
|
+
*/
|
|
17
|
+
user?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Local fallback cap: at most this many requests in this run (including its child runs),
|
|
20
|
+
* counted in this process and enforced even if the proxy is unreachable. Exceeding it
|
|
21
|
+
* throws {@link LocalCapExceeded} before the request is sent. SDK retries are not counted.
|
|
22
|
+
*/
|
|
23
|
+
maxCalls?: number;
|
|
24
|
+
}
|
|
25
|
+
/** One agent run. Created by `guard.run`; read it with `currentRun()`. */
|
|
26
|
+
export declare class Run {
|
|
27
|
+
#private;
|
|
28
|
+
readonly runId: string;
|
|
29
|
+
readonly budgetUsd: number | undefined;
|
|
30
|
+
readonly agent: string | undefined;
|
|
31
|
+
readonly user: string | undefined;
|
|
32
|
+
readonly maxCalls: number | undefined;
|
|
33
|
+
readonly parent: Run | undefined;
|
|
34
|
+
constructor(options?: RunOptions, parent?: Run);
|
|
35
|
+
get parentRunId(): string | undefined;
|
|
36
|
+
/** Requests made in this run and its child runs (SDK retries are not counted). */
|
|
37
|
+
get calls(): number;
|
|
38
|
+
/** Count one call against this run and every ancestor, or throw if a cap is hit. */
|
|
39
|
+
recordCall(): void;
|
|
40
|
+
headers(): Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
/** The innermost active run in this async context, or `undefined`. */
|
|
43
|
+
export declare function currentRun(): Run | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Run `fn` inside a new run. Every request made by a guarded client inside `fn` (including
|
|
46
|
+
* in awaited promises, timers and child tasks it starts) carries the run's headers. Runs
|
|
47
|
+
* nest: a run started inside another sends `x-tr-parent-run-id`.
|
|
48
|
+
*
|
|
49
|
+
* Returns whatever `fn` returns (a promise if `fn` is async).
|
|
50
|
+
*/
|
|
51
|
+
export declare function run<T>(fn: (run: Run) => T): T;
|
|
52
|
+
export declare function run<T>(options: RunOptions | string | undefined, fn: (run: Run) => T): T;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Run scopes: which run (and parent run) the current async context is executing in. */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { LOCAL_CAP_EXCEEDED, LocalCapExceeded } from './errors.js';
|
|
5
|
+
// Same rule the proxy applies to run ids and agent names.
|
|
6
|
+
const ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
7
|
+
const storage = new AsyncLocalStorage();
|
|
8
|
+
export function validateId(value, what) {
|
|
9
|
+
if (typeof value !== 'string' || !ID_RE.test(value)) {
|
|
10
|
+
throw new TypeError(`${what} must be 1-128 letters, digits, '.', '_' or '-' (got ${JSON.stringify(value)})`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
const USER_RE = /^[A-Za-z0-9._@+-]{1,128}$/;
|
|
15
|
+
/** Users may be emails. */
|
|
16
|
+
export function validateUser(value) {
|
|
17
|
+
if (typeof value !== 'string' || !USER_RE.test(value)) {
|
|
18
|
+
throw new TypeError(`user must be 1-128 letters, digits, '.', '_', '-', '@' or '+' (got ${JSON.stringify(value)})`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
export function newRunId() {
|
|
23
|
+
return `run_${randomUUID().replaceAll('-', '')}`;
|
|
24
|
+
}
|
|
25
|
+
/** One agent run. Created by `guard.run`; read it with `currentRun()`. */
|
|
26
|
+
export class Run {
|
|
27
|
+
runId;
|
|
28
|
+
budgetUsd;
|
|
29
|
+
agent;
|
|
30
|
+
user;
|
|
31
|
+
maxCalls;
|
|
32
|
+
parent;
|
|
33
|
+
#calls = 0;
|
|
34
|
+
constructor(options = {}, parent) {
|
|
35
|
+
this.runId = options.runId !== undefined ? validateId(options.runId, 'runId') : newRunId();
|
|
36
|
+
const { budgetUsd, maxCalls } = options;
|
|
37
|
+
if (budgetUsd !== undefined && (typeof budgetUsd !== 'number' || !Number.isFinite(budgetUsd) || budgetUsd <= 0)) {
|
|
38
|
+
throw new RangeError('budgetUsd must be a positive number of US dollars');
|
|
39
|
+
}
|
|
40
|
+
if (maxCalls !== undefined && (!Number.isInteger(maxCalls) || maxCalls < 1)) {
|
|
41
|
+
throw new RangeError('maxCalls must be a positive integer');
|
|
42
|
+
}
|
|
43
|
+
this.budgetUsd = budgetUsd;
|
|
44
|
+
this.agent = options.agent !== undefined ? validateId(options.agent, 'agent') : undefined;
|
|
45
|
+
this.user = options.user !== undefined ? validateUser(options.user) : parent?.user;
|
|
46
|
+
this.maxCalls = maxCalls;
|
|
47
|
+
this.parent = parent;
|
|
48
|
+
if (parent && parent.runId === this.runId) {
|
|
49
|
+
throw new RangeError('a run cannot be its own parent');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
get parentRunId() {
|
|
53
|
+
return this.parent?.runId;
|
|
54
|
+
}
|
|
55
|
+
/** Requests made in this run and its child runs (SDK retries are not counted). */
|
|
56
|
+
get calls() {
|
|
57
|
+
return this.#calls;
|
|
58
|
+
}
|
|
59
|
+
/** Count one call against this run and every ancestor, or throw if a cap is hit. */
|
|
60
|
+
recordCall() {
|
|
61
|
+
const chain = [];
|
|
62
|
+
for (let r = this; r; r = r.parent)
|
|
63
|
+
chain.push(r);
|
|
64
|
+
// Check every cap first so a refused call is not counted anywhere.
|
|
65
|
+
for (const r of chain) {
|
|
66
|
+
if (r.maxCalls !== undefined && r.#calls >= r.maxCalls) {
|
|
67
|
+
throw new LocalCapExceeded(`Tokenrein local cap: run ${r.runId} reached maxCalls=${r.maxCalls}; request not sent`, { runId: r.runId, errorType: LOCAL_CAP_EXCEEDED });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const r of chain)
|
|
71
|
+
r.#calls += 1;
|
|
72
|
+
}
|
|
73
|
+
headers() {
|
|
74
|
+
const h = { 'x-tr-run-id': this.runId };
|
|
75
|
+
if (this.budgetUsd !== undefined)
|
|
76
|
+
h['x-tr-run-budget-usd'] = String(this.budgetUsd);
|
|
77
|
+
if (this.parent)
|
|
78
|
+
h['x-tr-parent-run-id'] = this.parent.runId;
|
|
79
|
+
return h;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** The innermost active run in this async context, or `undefined`. */
|
|
83
|
+
export function currentRun() {
|
|
84
|
+
return storage.getStore();
|
|
85
|
+
}
|
|
86
|
+
export function run(a, b) {
|
|
87
|
+
const fn = typeof a === 'function' ? a : b;
|
|
88
|
+
if (typeof fn !== 'function')
|
|
89
|
+
throw new TypeError('guard.run needs a callback');
|
|
90
|
+
const options = typeof a === 'string' ? { runId: a } : typeof a === 'function' ? {} : (a ?? {});
|
|
91
|
+
const r = new Run(options, storage.getStore());
|
|
92
|
+
return storage.run(r, fn, r);
|
|
93
|
+
}
|