@agent-watch-ai/otel 0.0.0-stage → 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 +21 -0
- package/README.md +201 -2
- package/dist/call.d.ts +51 -0
- package/dist/call.js +289 -0
- package/dist/fingerprint.d.ts +79 -0
- package/dist/fingerprint.js +188 -0
- package/dist/gate.d.ts +109 -0
- package/dist/gate.js +498 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +10 -0
- package/dist/post.d.ts +54 -0
- package/dist/post.js +149 -0
- package/dist/processor.d.ts +103 -0
- package/dist/processor.js +220 -0
- package/dist/refusal.d.ts +26 -0
- package/dist/refusal.js +39 -0
- package/package.json +40 -4
package/dist/gate.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { trace } from '@opentelemetry/api';
|
|
3
|
+
import { REFUSED_ATTRIBUTE, isLlmSpan, reportedService, storable } from './call.js';
|
|
4
|
+
import { BodyTooLarge, Endpoint } from './post.js';
|
|
5
|
+
import { AgentWatchRefusal } from './refusal.js';
|
|
6
|
+
/**
|
|
7
|
+
* The runtime gate: before an LLM span's first HTTP request leaves the process, ask Agent Watch
|
|
8
|
+
* whether the agent may make it (plan Q1, Q2, D7). An LLM span is one the processor reports
|
|
9
|
+
* (`isLlmSpan`), so a refused call is always reported as one; tool, agent and embedding spans
|
|
10
|
+
* are never gated.
|
|
11
|
+
*
|
|
12
|
+
* What it must never do:
|
|
13
|
+
* 1. Fail the call for any reason but a `block` answer. Every other outcome (slow, down, a status
|
|
14
|
+
* that is not 2xx, a body it cannot read, a bug here) lets the call through and is counted as
|
|
15
|
+
* a fail-open, reported on the next decision request for the same agent. There is no
|
|
16
|
+
* fail-closed path and no switch for one.
|
|
17
|
+
* 2. Wait longer than `GATE_TIMEOUT_MILLIS`. A held answer costs no request at all, and neither
|
|
18
|
+
* does a call after a fail-open: the agent is held at allow for a while (`FAIL_OPEN_HOLD_MILLIS`).
|
|
19
|
+
* 3. Send content. The request names the agent (the span name), the service, the model and the
|
|
20
|
+
* deployment environment, and carries the counts it owes. Nothing from a message, tool or response.
|
|
21
|
+
*
|
|
22
|
+
* What it owes travels as one report batch per request (contract v2, AWT-335): the batch has a
|
|
23
|
+
* `report_id` and is sent again, unchanged, until a request carrying it is answered, so the
|
|
24
|
+
* platform writes it once however often it is resent. What is counted meanwhile waits for the
|
|
25
|
+
* next batch.
|
|
26
|
+
*/
|
|
27
|
+
/** The longest a call waits for an answer: the coding edge's `DEFAULT_ENFORCEMENT_TIMEOUT_MS`. */
|
|
28
|
+
export const GATE_TIMEOUT_MILLIS = 300;
|
|
29
|
+
/**
|
|
30
|
+
* How long a decision request may run after the call stopped waiting for it. Not cancelled at the
|
|
31
|
+
* gate timeout: a far-away gateway answers a cold request in more than 300 ms, and cancelling
|
|
32
|
+
* would throw the connection away and make every attempt cold. Its late answer is held.
|
|
33
|
+
*/
|
|
34
|
+
export const LATE_ANSWER_MILLIS = 5000;
|
|
35
|
+
/** A `hold_ms` above this is held for this long: a stale refusal must not outlive a cap raise by much. */
|
|
36
|
+
export const MAX_HOLD_MILLIS = 60_000;
|
|
37
|
+
/** How long a fail-open holds an allow: Agent Watch slow, down or failing. */
|
|
38
|
+
export const FAIL_OPEN_HOLD_MILLIS = 30_000;
|
|
39
|
+
/** How long a 401, 403 or 404 holds an allow: the route is missing or the token lacks the scope. */
|
|
40
|
+
export const FAIL_OPEN_AUTH_HOLD_MILLIS = 300_000;
|
|
41
|
+
/**
|
|
42
|
+
* Agents remembered at once; past it the least recently used one with nothing in flight and
|
|
43
|
+
* nothing owed is forgotten. One that owes reports is kept up to twice this many.
|
|
44
|
+
*/
|
|
45
|
+
export const MAX_AGENTS = 1000;
|
|
46
|
+
/** Token endpoints a model call depends on (Vertex on a workload identity): never gated. */
|
|
47
|
+
export const TOKEN_HOSTS = new Set(['metadata.google.internal', 'oauth2.googleapis.com', 'sts.googleapis.com']);
|
|
48
|
+
const DECISION_PATH = 'v1/enforcement/runtime-decision';
|
|
49
|
+
const ID_MAX = 200;
|
|
50
|
+
const MAX_AGENTS_OWING = 2 * MAX_AGENTS;
|
|
51
|
+
const AUTH_STATUSES = new Set([401, 403, 404]);
|
|
52
|
+
// An answer is two short fields; anything longer is not one.
|
|
53
|
+
const MAX_ANSWER_BYTES = 4096;
|
|
54
|
+
// The contract's ceiling on a reported count.
|
|
55
|
+
const REPORT_COUNT_MAX = 1_000_000_000;
|
|
56
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
57
|
+
class AnswerError extends Error {
|
|
58
|
+
reason;
|
|
59
|
+
holdMillis;
|
|
60
|
+
late;
|
|
61
|
+
// `late`: on a timeout, the request still running, whose answer may yet be held.
|
|
62
|
+
constructor(reason, holdMillis = FAIL_OPEN_HOLD_MILLIS, late) {
|
|
63
|
+
super(reason);
|
|
64
|
+
this.reason = reason;
|
|
65
|
+
this.holdMillis = holdMillis;
|
|
66
|
+
this.late = late;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// A count, clamped to the contract's ceiling.
|
|
70
|
+
function cap(count) {
|
|
71
|
+
return Math.min(REPORT_COUNT_MAX, count);
|
|
72
|
+
}
|
|
73
|
+
// One report batch: refusals answered here without asking, the feature they were answered from,
|
|
74
|
+
// and fail-opens per reason. Its id is drawn once, so a resent batch carries the same one.
|
|
75
|
+
class Reports {
|
|
76
|
+
id = randomUUID();
|
|
77
|
+
refused = 0;
|
|
78
|
+
featureId = null;
|
|
79
|
+
failOpens = new Map();
|
|
80
|
+
get empty() {
|
|
81
|
+
return this.refused === 0 && this.failOpens.size === 0;
|
|
82
|
+
}
|
|
83
|
+
// Refusals from blocks on different features never share a batch in practice (a batch is sent
|
|
84
|
+
// before the next answer); if they ever did, they are reported on no feature rather than a wrong one.
|
|
85
|
+
refuse(featureId) {
|
|
86
|
+
this.featureId = this.refused === 0 || this.featureId === featureId ? featureId : null;
|
|
87
|
+
this.refused = cap(this.refused + 1);
|
|
88
|
+
}
|
|
89
|
+
failOpen(reason) {
|
|
90
|
+
const at = new Date().toISOString();
|
|
91
|
+
const prior = this.failOpens.get(reason);
|
|
92
|
+
// `last_at` never precedes `first_at` (a 400) when the wall clock steps back.
|
|
93
|
+
this.failOpens.set(reason, prior === undefined
|
|
94
|
+
? { count: 1, first_at: at, last_at: at }
|
|
95
|
+
: { count: cap(prior.count + 1), first_at: prior.first_at, last_at: at < prior.first_at ? prior.first_at : at });
|
|
96
|
+
}
|
|
97
|
+
fields() {
|
|
98
|
+
if (this.empty) {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
const failOpens = [...this.failOpens].map(([reason, report]) => ({ reason, ...report }));
|
|
102
|
+
return {
|
|
103
|
+
report_id: this.id,
|
|
104
|
+
...(this.refused > 0 ? { refused_since_last: this.refused } : {}),
|
|
105
|
+
...(this.refused > 0 && this.featureId !== null ? { refused_feature_id: this.featureId } : {}),
|
|
106
|
+
...(failOpens.length > 0 ? { fail_opens: failOpens } : {})
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// One agent's held answer, its single request in flight, and what it owes. A hold set by a
|
|
111
|
+
// fail-open allows, and counts each call it lets through as one more fail-open.
|
|
112
|
+
class AgentGate {
|
|
113
|
+
answer;
|
|
114
|
+
held = null;
|
|
115
|
+
flight = null;
|
|
116
|
+
// The batch sent and not yet answered, resent as it is until it is.
|
|
117
|
+
sending = null;
|
|
118
|
+
// What is counted meanwhile: the next batch.
|
|
119
|
+
reports = new Reports();
|
|
120
|
+
// Bumped per request, so a late answer never overrides a newer one.
|
|
121
|
+
asked = 0;
|
|
122
|
+
constructor(answer) {
|
|
123
|
+
this.answer = answer;
|
|
124
|
+
}
|
|
125
|
+
// Whether forgetting it would lose a request in flight or reports owed.
|
|
126
|
+
get busy() {
|
|
127
|
+
return this.flight !== null;
|
|
128
|
+
}
|
|
129
|
+
get owes() {
|
|
130
|
+
return this.sending !== null || !this.reports.empty;
|
|
131
|
+
}
|
|
132
|
+
// `true` refuses the call. Never rejects.
|
|
133
|
+
async check(subject) {
|
|
134
|
+
try {
|
|
135
|
+
const held = this.held;
|
|
136
|
+
if (held !== null && held.until > Date.now()) {
|
|
137
|
+
if (held.failOpen !== undefined) {
|
|
138
|
+
this.reports.failOpen(held.failOpen);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return this.answeredHere(held.decision, held.featureId);
|
|
142
|
+
}
|
|
143
|
+
const joining = this.flight !== null;
|
|
144
|
+
this.flight ??= this.ask(subject).finally(() => {
|
|
145
|
+
this.flight = null;
|
|
146
|
+
});
|
|
147
|
+
const outcome = await this.flight;
|
|
148
|
+
if ('failOpen' in outcome) {
|
|
149
|
+
this.reports.failOpen(outcome.failOpen);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
// The call that asked is the one the platform answered; the ones that waited on it are
|
|
153
|
+
// answered here, and a refusal among them is reported like a held one.
|
|
154
|
+
return joining ? this.answeredHere(outcome.decision, outcome.featureId) : outcome.decision === 'block';
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
this.reports.failOpen('internal');
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
answeredHere(decision, featureId) {
|
|
162
|
+
if (decision === 'block') {
|
|
163
|
+
this.reports.refuse(featureId);
|
|
164
|
+
}
|
|
165
|
+
return decision === 'block';
|
|
166
|
+
}
|
|
167
|
+
async ask(subject) {
|
|
168
|
+
if (this.sending === null && !this.reports.empty) {
|
|
169
|
+
this.sending = this.reports;
|
|
170
|
+
this.reports = new Reports();
|
|
171
|
+
}
|
|
172
|
+
const sent = this.sending;
|
|
173
|
+
const generation = ++this.asked;
|
|
174
|
+
try {
|
|
175
|
+
const answer = await this.answer(JSON.stringify({ ...subjectFields(subject), ...sent?.fields() }));
|
|
176
|
+
this.answered(answer, sent, generation);
|
|
177
|
+
return { decision: answer.decision, featureId: answer.featureId };
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
const failed = error instanceof AnswerError ? error : new AnswerError('internal');
|
|
181
|
+
// Maybe not recorded, so `sending` stays and is resent with its report_id once the hold runs out.
|
|
182
|
+
this.held = { decision: 'allow', until: Date.now() + failed.holdMillis, featureId: null, failOpen: failed.reason };
|
|
183
|
+
// Still on the wire after a timeout: its answer replaces the fail-open hold, and a failure
|
|
184
|
+
// holds as that failure would have (a late 401 is an auth hold, not a timeout one).
|
|
185
|
+
failed.late?.then((answer) => this.answered(answer, sent, generation), (late) => {
|
|
186
|
+
if (this.asked === generation && late instanceof AnswerError) {
|
|
187
|
+
this.held = { decision: 'allow', until: Date.now() + late.holdMillis, featureId: null, failOpen: late.reason };
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return { failOpen: failed.reason };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Answered, so recorded: the next batch is the one counted meanwhile. A late answer never
|
|
194
|
+
// overrides a newer request's hold.
|
|
195
|
+
answered(answer, sent, generation) {
|
|
196
|
+
if (this.sending === sent) {
|
|
197
|
+
this.sending = null;
|
|
198
|
+
}
|
|
199
|
+
if (this.asked === generation) {
|
|
200
|
+
this.held = answer.holdMillis > 0
|
|
201
|
+
? { decision: answer.decision, until: Date.now() + Math.min(answer.holdMillis, MAX_HOLD_MILLIS), featureId: answer.featureId }
|
|
202
|
+
: null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Asks the decision route, holds its answers per agent, and gates `fetch` requests made inside LLM spans. */
|
|
207
|
+
export class RuntimeGate {
|
|
208
|
+
endpoint;
|
|
209
|
+
// Least recently used first.
|
|
210
|
+
agents = new Map();
|
|
211
|
+
spans = new WeakMap();
|
|
212
|
+
/**
|
|
213
|
+
* Point the gate at a decision route.
|
|
214
|
+
* @param token SDK token scoped `ingest:production`.
|
|
215
|
+
* @param endpoint Ingest base URL; `v1/enforcement/runtime-decision` is joined onto it.
|
|
216
|
+
*/
|
|
217
|
+
constructor(token, endpoint) {
|
|
218
|
+
this.endpoint = new Endpoint(token, endpoint, DECISION_PATH);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Whether this agent's next call is refused. Never rejects, and settles within the gate timeout.
|
|
222
|
+
* @param subject The agent and what is stated about it.
|
|
223
|
+
* @returns `true` to refuse the call.
|
|
224
|
+
*/
|
|
225
|
+
check(subject) {
|
|
226
|
+
return this.agentFor(subject).check(subject);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* `fetch`, gated: the first request inside an LLM span waits for the span's decision; every
|
|
230
|
+
* later request in that span gets the same one. Anything else goes straight through.
|
|
231
|
+
* @param original The `fetch` this one replaced.
|
|
232
|
+
* @param input As `fetch`.
|
|
233
|
+
* @param init As `fetch`.
|
|
234
|
+
* @returns What `original` returns, or a rejection with `AgentWatchRefusal`, or with
|
|
235
|
+
* `init.signal`'s reason when it aborts while the decision is awaited.
|
|
236
|
+
*/
|
|
237
|
+
fetch(original, input, init) {
|
|
238
|
+
let refused;
|
|
239
|
+
try {
|
|
240
|
+
refused = this.decisionFor(input);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
refused = null;
|
|
244
|
+
}
|
|
245
|
+
if (refused === null) {
|
|
246
|
+
return original(input, init);
|
|
247
|
+
}
|
|
248
|
+
return unlessAborted(refused, init?.signal ?? requestSignal(input)).then((isRefused) => {
|
|
249
|
+
if (isRefused) {
|
|
250
|
+
throw new AgentWatchRefusal();
|
|
251
|
+
}
|
|
252
|
+
return original(input, init);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/** Release the sockets. Uninstall it first: a closed gate is never consulted. */
|
|
256
|
+
close() {
|
|
257
|
+
this.endpoint.destroy();
|
|
258
|
+
}
|
|
259
|
+
agentFor(subject) {
|
|
260
|
+
const key = JSON.stringify([subject.agentName, subject.deploymentEnvironment]);
|
|
261
|
+
const known = this.agents.get(key);
|
|
262
|
+
if (known !== undefined) {
|
|
263
|
+
this.agents.delete(key);
|
|
264
|
+
this.agents.set(key, known);
|
|
265
|
+
return known;
|
|
266
|
+
}
|
|
267
|
+
this.evict();
|
|
268
|
+
const created = new AgentGate((body) => this.decide(body));
|
|
269
|
+
// Full of agents that are all in flight or owing: this one is asked about but not
|
|
270
|
+
// remembered, so the map stays bounded whatever the application fans out.
|
|
271
|
+
if (this.agents.size < MAX_AGENTS_OWING) {
|
|
272
|
+
this.agents.set(key, created);
|
|
273
|
+
}
|
|
274
|
+
return created;
|
|
275
|
+
}
|
|
276
|
+
// Forget the least recently used agent with nothing in flight and nothing owed; past
|
|
277
|
+
// MAX_AGENTS_OWING, the least recently used one that owes. Never one with a request in flight.
|
|
278
|
+
evict() {
|
|
279
|
+
if (this.agents.size < MAX_AGENTS) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
let owing = null;
|
|
283
|
+
for (const [key, agent] of this.agents) {
|
|
284
|
+
if (agent.busy) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!agent.owes) {
|
|
288
|
+
this.agents.delete(key);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
owing ??= key;
|
|
292
|
+
}
|
|
293
|
+
if (owing !== null && this.agents.size >= MAX_AGENTS_OWING) {
|
|
294
|
+
this.agents.delete(owing);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// The span's decision, or `null` when this request is not gated. The span is checked before
|
|
298
|
+
// the URL: the URL is parsed once per gated span, not on every request the application makes.
|
|
299
|
+
decisionFor(input) {
|
|
300
|
+
const span = trace.getActiveSpan();
|
|
301
|
+
if (span === undefined) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
const known = this.spans.get(span);
|
|
305
|
+
if (known !== undefined) {
|
|
306
|
+
// A token request in a refused span still goes through.
|
|
307
|
+
return known.then((refused) => refused && !isTokenRequest(input));
|
|
308
|
+
}
|
|
309
|
+
// The SDK's span carries its attributes, name and resource; the API's interface does not.
|
|
310
|
+
// A sampled-out span (NonRecordingSpan) carries none, so it is never gated.
|
|
311
|
+
const { attributes, name, resource } = span;
|
|
312
|
+
if (attributes === undefined || !isLlmSpan({ attributes }) || isTokenRequest(input)) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const stated = resource?.attributes ?? {};
|
|
316
|
+
const decided = this.check({
|
|
317
|
+
agentName: storable(name, ID_MAX) ?? 'unnamed',
|
|
318
|
+
serviceName: reportedService(stated['service.name']),
|
|
319
|
+
model: storable(attributes['llm.model_name'], ID_MAX) ?? storable(attributes['llm.model'], ID_MAX) ?? storable(attributes['gen_ai.request.model'], ID_MAX),
|
|
320
|
+
deploymentEnvironment: environmentOf(stated)
|
|
321
|
+
}).then((refused) => {
|
|
322
|
+
if (refused) {
|
|
323
|
+
markRefused(span);
|
|
324
|
+
}
|
|
325
|
+
return refused;
|
|
326
|
+
});
|
|
327
|
+
this.spans.set(span, decided);
|
|
328
|
+
return decided;
|
|
329
|
+
}
|
|
330
|
+
// Settles within the gate timeout, with the fail-open reason on failure. The socket and the
|
|
331
|
+
// timer are ref'd while a caller awaits it: a process with nothing else to do must not exit
|
|
332
|
+
// before the model call it is waiting to make. Past the timeout the request runs on, unref'd
|
|
333
|
+
// and capped at LATE_ANSWER_MILLIS, and the timeout carries it as `late`.
|
|
334
|
+
decide(body) {
|
|
335
|
+
let timer;
|
|
336
|
+
const waiting = new AbortController();
|
|
337
|
+
const capped = new AbortController();
|
|
338
|
+
const cap = setTimeout(() => capped.abort(), LATE_ANSWER_MILLIS).unref();
|
|
339
|
+
const answered = this.endpoint.postJson(body, { signal: capped.signal, maxBytes: MAX_ANSWER_BYTES, holdProcess: waiting.signal }).then(readPosted, (error) => {
|
|
340
|
+
throw new AnswerError(error instanceof BodyTooLarge ? 'unreadable' : 'unreachable');
|
|
341
|
+
}).finally(() => clearTimeout(cap));
|
|
342
|
+
const timedOut = new Promise((_resolve, reject) => {
|
|
343
|
+
timer = setTimeout(() => {
|
|
344
|
+
waiting.abort();
|
|
345
|
+
reject(new AnswerError('timeout', FAIL_OPEN_HOLD_MILLIS, answered));
|
|
346
|
+
}, GATE_TIMEOUT_MILLIS);
|
|
347
|
+
});
|
|
348
|
+
return Promise.race([answered, timedOut]).finally(() => clearTimeout(timer));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const SLOT = Symbol.for('agent-watch.otel.fetch-gate');
|
|
352
|
+
function liveGate() {
|
|
353
|
+
return globalThis[SLOT]?.at(-1);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Make `gate` the one `globalThis.fetch` consults, until it is uninstalled. Wraps `fetch` once per
|
|
357
|
+
* process, however many processors or copies of this package register; the most recent live gate
|
|
358
|
+
* is the one consulted.
|
|
359
|
+
* @param gate The gate to consult.
|
|
360
|
+
*/
|
|
361
|
+
export function installGate(gate) {
|
|
362
|
+
const host = globalThis;
|
|
363
|
+
if (host[SLOT] === undefined) {
|
|
364
|
+
const original = host.fetch;
|
|
365
|
+
host[SLOT] = [];
|
|
366
|
+
if (typeof original === 'function') {
|
|
367
|
+
host.fetch = function fetch(input, init) {
|
|
368
|
+
const live = liveGate();
|
|
369
|
+
return live === undefined ? original(input, init) : live.fetch(original, input, init);
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
host[SLOT].push(gate);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Stop consulting `gate`; the gate installed before it, if still live, is consulted again. `fetch`
|
|
377
|
+
* stays wrapped and passes through once no gate is live.
|
|
378
|
+
* @param gate The gate being shut down.
|
|
379
|
+
*/
|
|
380
|
+
export function uninstallGate(gate) {
|
|
381
|
+
const gates = globalThis[SLOT];
|
|
382
|
+
const index = gates?.lastIndexOf(gate) ?? -1;
|
|
383
|
+
if (index >= 0) {
|
|
384
|
+
gates?.splice(index, 1);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The fallback for calls the gate does not see (`node:http`, gRPC, other transports): await it
|
|
389
|
+
* before the call. Resolves at once when no processor is registered.
|
|
390
|
+
* @param agentName The agent the call is charged to, as the span would name it.
|
|
391
|
+
* @returns When the call may go ahead.
|
|
392
|
+
* @throws {AgentWatchRefusal} When an Agent Watch budget refuses it.
|
|
393
|
+
*/
|
|
394
|
+
export async function guard(agentName) {
|
|
395
|
+
const gate = liveGate();
|
|
396
|
+
if (gate === undefined) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const refused = await gate.check({
|
|
400
|
+
agentName: storable(agentName, ID_MAX) ?? 'unnamed',
|
|
401
|
+
serviceName: null,
|
|
402
|
+
model: null,
|
|
403
|
+
deploymentEnvironment: environmentOf(resourceFromEnvironment())
|
|
404
|
+
});
|
|
405
|
+
if (refused) {
|
|
406
|
+
throw new AgentWatchRefusal();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// `init.signal` aborting while the decision is awaited rejects at once with its reason, as
|
|
410
|
+
// `fetch` itself would.
|
|
411
|
+
function unlessAborted(pending, signal) {
|
|
412
|
+
if (signal === undefined || signal === null) {
|
|
413
|
+
return pending;
|
|
414
|
+
}
|
|
415
|
+
if (signal.aborted) {
|
|
416
|
+
return Promise.reject(signal.reason);
|
|
417
|
+
}
|
|
418
|
+
return new Promise((resolve, reject) => {
|
|
419
|
+
const onAbort = () => reject(signal.reason);
|
|
420
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
421
|
+
void pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
// A `Request` carries its own signal; `init.signal` overrides it, as in `fetch`.
|
|
425
|
+
function requestSignal(input) {
|
|
426
|
+
return typeof Request !== 'undefined' && input instanceof Request ? input.signal : undefined;
|
|
427
|
+
}
|
|
428
|
+
function readPosted(posted) {
|
|
429
|
+
if (posted.status < 200 || posted.status > 299) {
|
|
430
|
+
throw new AnswerError('http_status', AUTH_STATUSES.has(posted.status) ? FAIL_OPEN_AUTH_HOLD_MILLIS : FAIL_OPEN_HOLD_MILLIS);
|
|
431
|
+
}
|
|
432
|
+
const answer = readAnswer(posted.text);
|
|
433
|
+
if (answer === null) {
|
|
434
|
+
throw new AnswerError('unreadable');
|
|
435
|
+
}
|
|
436
|
+
return answer;
|
|
437
|
+
}
|
|
438
|
+
// `deployment.environment.name` (semantic conventions 1.27+), then the older `deployment.environment`.
|
|
439
|
+
function environmentOf(attributes) {
|
|
440
|
+
return storable(attributes['deployment.environment.name'], ID_MAX) ?? storable(attributes['deployment.environment'], ID_MAX);
|
|
441
|
+
}
|
|
442
|
+
// OTEL_RESOURCE_ATTRIBUTES (`key=value,key=value`, values percent-encoded): what a resource
|
|
443
|
+
// detector would read, for `guard`, which has no span.
|
|
444
|
+
function resourceFromEnvironment() {
|
|
445
|
+
const attributes = {};
|
|
446
|
+
for (const pair of (process.env.OTEL_RESOURCE_ATTRIBUTES ?? '').split(',')) {
|
|
447
|
+
const separator = pair.indexOf('=');
|
|
448
|
+
if (separator > 0) {
|
|
449
|
+
try {
|
|
450
|
+
attributes[pair.slice(0, separator).trim()] = decodeURIComponent(pair.slice(separator + 1).trim());
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// A value that is not percent-encoded properly is left out, as the SDK's detector does.
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return attributes;
|
|
458
|
+
}
|
|
459
|
+
function subjectFields(subject) {
|
|
460
|
+
return {
|
|
461
|
+
agent_name: subject.agentName,
|
|
462
|
+
...(subject.serviceName === null ? {} : { service_name: subject.serviceName }),
|
|
463
|
+
...(subject.model === null ? {} : { model: subject.model }),
|
|
464
|
+
...(subject.deploymentEnvironment === null ? {} : { deployment_environment: subject.deploymentEnvironment })
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function readAnswer(text) {
|
|
468
|
+
let parsed;
|
|
469
|
+
try {
|
|
470
|
+
parsed = JSON.parse(text);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const { decision, hold_ms: hold, feature_id: featureId } = parsed;
|
|
479
|
+
if ((decision !== 'allow' && decision !== 'block') || typeof hold !== 'number' || !Number.isFinite(hold) || hold < 0) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
// Not a uuid, it is not echoed: the platform would drop it anyway.
|
|
483
|
+
return { decision, holdMillis: hold, featureId: decision === 'block' && typeof featureId === 'string' && UUID.test(featureId) ? featureId : null };
|
|
484
|
+
}
|
|
485
|
+
// A URL that does not parse is not a token request; `fetch` itself rejects it.
|
|
486
|
+
function isTokenRequest(input) {
|
|
487
|
+
const href = typeof input === 'string' || 'href' in input ? String(input) : input.url;
|
|
488
|
+
return URL.canParse(href) && TOKEN_HOSTS.has(new URL(href).hostname);
|
|
489
|
+
}
|
|
490
|
+
// The processor reads it back in `buildCall`: a refused call is reported as one, with no spend.
|
|
491
|
+
function markRefused(span) {
|
|
492
|
+
try {
|
|
493
|
+
span.setAttribute(REFUSED_ATTRIBUTE, true);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// A span that cannot take it is still refused.
|
|
497
|
+
}
|
|
498
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AgentWatchSpanProcessor, BATCH_MAX_CALLS, type AgentWatchSpanProcessorOptions, type ProcessorStats } from './processor.js';
|
|
2
|
+
export { PLATFORM, buildCall, isLlmSpan, type CallSpan, type RuntimeCall } from './call.js';
|
|
3
|
+
export { FINGERPRINT_SCHEME, MAX_TOOL_DEPTH } from './fingerprint.js';
|
|
4
|
+
export { AgentWatchRefusal, REFUSAL_MESSAGE, isAgentWatchRefusal } from './refusal.js';
|
|
5
|
+
export { GATE_TIMEOUT_MILLIS, guard } from './gate.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @agent-watch-ai/otel: production LLM spend from an OpenTelemetry span processor, as digests only.
|
|
2
|
+
//
|
|
3
|
+
// provider = new NodeTracerProvider({ spanProcessors: [yours, new AgentWatchSpanProcessor()] });
|
|
4
|
+
//
|
|
5
|
+
// The customer's own processors keep receiving every span exactly as before.
|
|
6
|
+
export { AgentWatchSpanProcessor, BATCH_MAX_CALLS } from './processor.js';
|
|
7
|
+
export { PLATFORM, buildCall, isLlmSpan } from './call.js';
|
|
8
|
+
export { FINGERPRINT_SCHEME, MAX_TOOL_DEPTH } from './fingerprint.js';
|
|
9
|
+
export { AgentWatchRefusal, REFUSAL_MESSAGE, isAgentWatchRefusal } from './refusal.js';
|
|
10
|
+
export { GATE_TIMEOUT_MILLIS, guard } from './gate.js';
|
package/dist/post.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** What came back: the status, and the body when it was read. */
|
|
2
|
+
export interface Posted {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly text: string;
|
|
5
|
+
}
|
|
6
|
+
/** Per-request options. */
|
|
7
|
+
export interface PostOptions {
|
|
8
|
+
/** Aborts the request. */
|
|
9
|
+
readonly signal?: AbortSignal;
|
|
10
|
+
/** Read a 2xx body up to this many bytes (more rejects with `BodyTooLarge`); 0 reads none. */
|
|
11
|
+
readonly maxBytes?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Keep the process alive while the request is in flight: a caller is awaiting it. A signal
|
|
14
|
+
* holds it until the request settles or the signal aborts (the caller stopped waiting).
|
|
15
|
+
*/
|
|
16
|
+
readonly holdProcess?: boolean | AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
/** A 2xx body longer than `maxBytes`. */
|
|
19
|
+
export declare class BodyTooLarge extends Error {
|
|
20
|
+
}
|
|
21
|
+
/** One Agent Watch route, with the token and the agent its requests use. */
|
|
22
|
+
export declare class Endpoint {
|
|
23
|
+
private readonly token;
|
|
24
|
+
private readonly url;
|
|
25
|
+
private readonly agent;
|
|
26
|
+
private readonly sockets;
|
|
27
|
+
private held;
|
|
28
|
+
/**
|
|
29
|
+
* Join the route onto the base URL and give it its own agent.
|
|
30
|
+
* @throws {Error} Plain http to anything but loopback.
|
|
31
|
+
* @param token SDK token, sent as a bearer token.
|
|
32
|
+
* @param base Ingest base URL.
|
|
33
|
+
* @param path The route, joined onto `base`.
|
|
34
|
+
*/
|
|
35
|
+
constructor(token: string, base: string, path: string);
|
|
36
|
+
/**
|
|
37
|
+
* POST `body` as JSON. Resolves with any status, a redirect included (never followed: it would
|
|
38
|
+
* carry the token elsewhere); rejects on a network error, an abort or an oversized body.
|
|
39
|
+
* @param body The JSON text.
|
|
40
|
+
* @param options See `PostOptions`.
|
|
41
|
+
* @returns The status, and the body when `maxBytes` asked for it and the status is 2xx.
|
|
42
|
+
*/
|
|
43
|
+
postJson(body: string, options?: PostOptions): Promise<Posted>;
|
|
44
|
+
/**
|
|
45
|
+
* Keep the process alive for this endpoint's requests while `work` runs: awaiting a promise
|
|
46
|
+
* does not keep Node's loop alive, so a short job awaiting delivery would otherwise exit first.
|
|
47
|
+
* Bounded by whatever bounds `work`.
|
|
48
|
+
* @param work What the caller awaits.
|
|
49
|
+
* @returns `work`'s result.
|
|
50
|
+
*/
|
|
51
|
+
heldWhile<T>(work: () => Promise<T>): Promise<T>;
|
|
52
|
+
/** Release the sockets. */
|
|
53
|
+
destroy(): void;
|
|
54
|
+
}
|