@parcha/agentrun-kernel 0.0.0-bootstrap.0 → 0.1.0-beta.29
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/CHANGELOG.md +135 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +53 -0
- package/dist/delegate.d.ts +87 -0
- package/dist/delegate.js +143 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +37 -0
- package/dist/host/alive.d.ts +2 -0
- package/dist/host/alive.js +28 -0
- package/dist/host/box-slot.d.ts +26 -0
- package/dist/host/box-slot.js +112 -0
- package/dist/host/box.d.ts +104 -0
- package/dist/host/box.js +16 -0
- package/dist/host/egress-proxy.d.ts +45 -0
- package/dist/host/egress-proxy.js +416 -0
- package/dist/host/egress-table.d.ts +24 -0
- package/dist/host/egress-table.js +165 -0
- package/dist/host/index.d.ts +16 -0
- package/dist/host/index.js +12 -0
- package/dist/host/overhead.d.ts +53 -0
- package/dist/host/overhead.js +75 -0
- package/dist/host/parent-death.d.ts +15 -0
- package/dist/host/parent-death.js +42 -0
- package/dist/host/parent-watch.d.ts +1 -0
- package/dist/host/parent-watch.js +38 -0
- package/dist/host/pinned-binary.d.ts +6 -0
- package/dist/host/pinned-binary.js +48 -0
- package/dist/host/proxy.d.ts +179 -0
- package/dist/host/proxy.js +629 -0
- package/dist/host/serial.d.ts +3 -0
- package/dist/host/serial.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/kernel/blobs.d.ts +16 -0
- package/dist/kernel/blobs.js +59 -0
- package/dist/kernel/drive.d.ts +35 -0
- package/dist/kernel/drive.js +202 -0
- package/dist/kernel/errors.d.ts +27 -0
- package/dist/kernel/errors.js +42 -0
- package/dist/kernel/follow-up.d.ts +67 -0
- package/dist/kernel/follow-up.js +146 -0
- package/dist/kernel/halt.d.ts +11 -0
- package/dist/kernel/halt.js +17 -0
- package/dist/kernel/index.d.ts +8 -0
- package/dist/kernel/index.js +5 -0
- package/dist/kernel/types.d.ts +120 -0
- package/dist/kernel/types.js +1 -0
- package/dist/process/builtins.d.ts +142 -0
- package/dist/process/builtins.js +207 -0
- package/dist/process/inbox.d.ts +29 -0
- package/dist/process/inbox.js +52 -0
- package/dist/process/index.d.ts +9 -0
- package/dist/process/index.js +11 -0
- package/dist/process/journal.d.ts +111 -0
- package/dist/process/journal.js +232 -0
- package/dist/process/records.d.ts +143 -0
- package/dist/process/records.js +142 -0
- package/dist/process/recovery.d.ts +76 -0
- package/dist/process/recovery.js +139 -0
- package/dist/process/settle.d.ts +11 -0
- package/dist/process/settle.js +20 -0
- package/dist/process/tail.d.ts +20 -0
- package/dist/process/tail.js +53 -0
- package/dist/process/tools.d.ts +105 -0
- package/dist/process/tools.js +120 -0
- package/dist/process/zod.d.ts +32 -0
- package/dist/process/zod.js +109 -0
- package/dist/runner.d.ts +154 -0
- package/dist/runner.js +547 -0
- package/dist/schema-check-cases.d.ts +7 -0
- package/dist/schema-check-cases.js +73 -0
- package/dist/schema-check.d.ts +3 -0
- package/dist/schema-check.js +88 -0
- package/dist/skill-files.d.ts +23 -0
- package/dist/skill-files.js +136 -0
- package/dist/testing.d.ts +91 -0
- package/dist/testing.js +196 -0
- package/dist/types.d.ts +237 -0
- package/dist/types.js +10 -0
- package/package.json +76 -1
- package/vendor/durable-harness/UPSTREAM.json +67 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// A session's model proxy, written for AgentRun: the provider a harness is pointed at, on the loopback interface. The
|
|
2
|
+
// harness holds only the session's own key; the provider's credential never reaches it. The proxy is provider-neutral,
|
|
3
|
+
// and a `ProviderPolicy` says what one provider's API may carry (its routes, how its client sends a key, the body it may
|
|
4
|
+
// forward, where a response reports its usage). The core:
|
|
5
|
+
// - admits a request only with the session's key (compared in constant time), on a route the policy lists, by exact
|
|
6
|
+
// path text, with no query or one the route lists exactly: an absolute URL, `..`, `//` or `%` escapes never match. A
|
|
7
|
+
// route may be answered here with a status alone, with no key (a client's connectivity check), or be free (a token
|
|
8
|
+
// count, which the runner never pays);
|
|
9
|
+
// - parses the body strictly (one JSON value, no duplicate key at any depth, so a check on one `model` cannot pass while
|
|
10
|
+
// the provider reads another), then has the policy shape it (or refuse it);
|
|
11
|
+
// - asks the runner before every paid request (`beforeRequest`, where a turn cap or the host's budget refuses it) and
|
|
12
|
+
// answers a refusal with a status the client does not retry, adds the provider's credential, forwards to the
|
|
13
|
+
// provider's origin and the route's own path (never text from the request), streams the answer back, and reports the
|
|
14
|
+
// response's usage once it completes (`afterRequest`). Every paid request forwarded is booked exactly once: a response
|
|
15
|
+
// cut short, or one that says no usage, is booked at an unknown cost.
|
|
16
|
+
// Model spend is accounted only there, by the runner: the proxy keeps no ledger of its own.
|
|
17
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
/** A header a host may not set on forwarded requests: the credential's, the transport's, and the provider's protocol. */
|
|
20
|
+
const RESERVED_HEADER = /^(?:authorization|proxy-authorization|x-api-key|host|content-length|content-type|transfer-encoding|connection|cookie|anthropic-.*)$/i;
|
|
21
|
+
const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/;
|
|
22
|
+
/** What is wrong with headers a host would add to forwarded requests: a name that is not a token or is reserved, a value
|
|
23
|
+
* an HTTP header cannot carry (a line break or other control character, or a character beyond Latin-1, which the fetch
|
|
24
|
+
* the proxy forwards with refuses) or over 1 KiB. Empty when they are fine. */
|
|
25
|
+
export function hostHeaderProblems(headers) {
|
|
26
|
+
const problems = [];
|
|
27
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
28
|
+
if (!HEADER_NAME.test(name))
|
|
29
|
+
problems.push(`the header name ${JSON.stringify(name.slice(0, 80))} is not a token`);
|
|
30
|
+
else if (RESERVED_HEADER.test(name))
|
|
31
|
+
problems.push(`the header ${name} is the proxy's own (credential, transport or protocol), not a host's`);
|
|
32
|
+
if (typeof value !== "string" || /[^\t\x20-\x7e\x80-\xff]/.test(value) || value.length > 1024)
|
|
33
|
+
problems.push(`the header ${name.slice(0, 80)}'s value must be one line of text an HTTP header can carry (Latin-1, no control characters), at most 1024 characters`);
|
|
34
|
+
}
|
|
35
|
+
return problems;
|
|
36
|
+
}
|
|
37
|
+
const UNSAFE_PATH = /[^A-Za-z0-9/_.\-?=&]|\.\.|\/\/|^[^/]|\?.*\?/;
|
|
38
|
+
const MAX_BODY = 32 * 1024 * 1024;
|
|
39
|
+
/** How much of a response's start and end the proxy keeps to read its usage from. */
|
|
40
|
+
const USAGE_WINDOW = 64 * 1024;
|
|
41
|
+
/** A response header that names the request: the provider's `request-id` (`x-request-id`), or a gateway's own call id
|
|
42
|
+
* (LiteLLM's `x-litellm-call-id`, the id its spend log records; `llm_provider-request-id`, the provider's passed on). */
|
|
43
|
+
const ID_HEADER = /^(?:[a-z0-9]+[-_])*(?:request|call)[-_]id$/;
|
|
44
|
+
/** What an id looks like: nothing else (a cookie, a token, prose) is ever kept. */
|
|
45
|
+
const ID_VALUE = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
46
|
+
/** The ids a response's headers give its request, by header name, so a run's books can be joined to the provider's (or
|
|
47
|
+
* a gateway's) request by request; undefined when there are none. Only id-named headers with id-shaped values. */
|
|
48
|
+
export function providerIdsOf(headers) {
|
|
49
|
+
const ids = {};
|
|
50
|
+
headers.forEach((value, name) => {
|
|
51
|
+
if (ID_HEADER.test(name) && ID_VALUE.test(value))
|
|
52
|
+
ids[name] = value;
|
|
53
|
+
});
|
|
54
|
+
return Object.keys(ids).length ? ids : undefined;
|
|
55
|
+
}
|
|
56
|
+
/** LiteLLM's figures for what a response cost: its own (`x-litellm-response-cost`), and the figure before its discount
|
|
57
|
+
* and margin (`-original`), which is the same only when both are reported 0. */
|
|
58
|
+
const COST_HEADER = "x-litellm-response-cost";
|
|
59
|
+
const COST_PARTS = ["x-litellm-response-cost-discount-amount", "x-litellm-response-cost-margin-amount"];
|
|
60
|
+
/** A cost as a gateway writes it: a decimal number, an exponent allowed (`3.7e-06`); nothing else is read as one. */
|
|
61
|
+
const COST_VALUE = /^\d{1,9}(?:\.\d{1,30})?(?:[eE][-+]?\d{1,3})?$/;
|
|
62
|
+
/** No one request costs this much: a figure above it is not believed. */
|
|
63
|
+
const MAX_REQUEST_USD = 1000;
|
|
64
|
+
const costOf = (value) => {
|
|
65
|
+
if (value === null || !COST_VALUE.test(value.trim()))
|
|
66
|
+
return undefined;
|
|
67
|
+
const usd = Number(value.trim());
|
|
68
|
+
return Number.isFinite(usd) && usd > 0 && usd <= MAX_REQUEST_USD ? usd : undefined;
|
|
69
|
+
};
|
|
70
|
+
/** What a response's headers say it cost, when they say so: LiteLLM's `x-litellm-response-cost`, or its `-original` when
|
|
71
|
+
* the discount and margin it reports are both exactly 0. Only a positive number counts. A missing, malformed or zero
|
|
72
|
+
* figure is unknown, never $0: a streamed answer's headers leave before it is priced, and LiteLLM writes 0.0 there. */
|
|
73
|
+
export function reportedCostOf(headers) {
|
|
74
|
+
const own = costOf(headers.get(COST_HEADER));
|
|
75
|
+
if (own !== undefined)
|
|
76
|
+
return own;
|
|
77
|
+
if (!COST_PARTS.every((name) => /^0(?:\.0+)?$/.test(headers.get(name)?.trim() ?? "")))
|
|
78
|
+
return undefined;
|
|
79
|
+
return costOf(headers.get(`${COST_HEADER}-original`));
|
|
80
|
+
}
|
|
81
|
+
export class ProxyRefusal extends Error {
|
|
82
|
+
status;
|
|
83
|
+
constructor(status, message) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.status = status;
|
|
86
|
+
this.name = "ProxyRefusal";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Match a request's method and path text against the routes, never parsing the path as a URL: the route, and the
|
|
90
|
+
* query it carried, which is one the route lists exactly, or none. */
|
|
91
|
+
export function matchRoute(routes, method, path) {
|
|
92
|
+
if (typeof path !== "string" || path.length === 0 || path.length > 2048 || UNSAFE_PATH.test(path))
|
|
93
|
+
throw new ProxyRefusal(403, "the path is not one the table can match");
|
|
94
|
+
const at = path.indexOf("?");
|
|
95
|
+
const pathname = at < 0 ? path : path.slice(0, at);
|
|
96
|
+
const query = at < 0 ? "" : path.slice(at + 1);
|
|
97
|
+
const route = routes.find((r) => r.method === method && r.path === pathname);
|
|
98
|
+
if (!route)
|
|
99
|
+
throw new ProxyRefusal(403, `${method.slice(0, 16)} ${pathname.slice(0, 128)} is not in the table`);
|
|
100
|
+
if (at >= 0 && !route.query?.includes(query))
|
|
101
|
+
throw new ProxyRefusal(403, `the query on ${pathname.slice(0, 128)} is not in the table`);
|
|
102
|
+
return { route, query };
|
|
103
|
+
}
|
|
104
|
+
/** Parse one JSON value, refusing a duplicate key at any depth and nesting past 64 levels. */
|
|
105
|
+
export function parseStrict(text) {
|
|
106
|
+
let i = 0;
|
|
107
|
+
const fail = (why) => { throw new ProxyRefusal(400, `the body is not strict JSON: ${why}`); };
|
|
108
|
+
const ws = () => { while (i < text.length && /[ \t\n\r]/.test(text[i]))
|
|
109
|
+
i++; };
|
|
110
|
+
const str = () => {
|
|
111
|
+
const start = i;
|
|
112
|
+
i++;
|
|
113
|
+
while (i < text.length && text[i] !== '"') {
|
|
114
|
+
if (text[i] === "\\")
|
|
115
|
+
i++;
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
if (text[i] !== '"')
|
|
119
|
+
fail("an unterminated string");
|
|
120
|
+
i++;
|
|
121
|
+
return JSON.parse(text.slice(start, i));
|
|
122
|
+
};
|
|
123
|
+
const value = (depth) => {
|
|
124
|
+
if (depth > 64)
|
|
125
|
+
fail("nested too deep");
|
|
126
|
+
ws();
|
|
127
|
+
const c = text[i];
|
|
128
|
+
if (c === "{") {
|
|
129
|
+
i++;
|
|
130
|
+
const out = {};
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
ws();
|
|
133
|
+
if (text[i] === "}") {
|
|
134
|
+
i++;
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
for (;;) {
|
|
138
|
+
ws();
|
|
139
|
+
if (text[i] !== '"')
|
|
140
|
+
fail("an object key");
|
|
141
|
+
const key = str();
|
|
142
|
+
if (seen.has(key))
|
|
143
|
+
fail(`the key ${JSON.stringify(key).slice(0, 40)} appears twice`);
|
|
144
|
+
seen.add(key);
|
|
145
|
+
ws();
|
|
146
|
+
if (text[i] !== ":")
|
|
147
|
+
fail("a colon");
|
|
148
|
+
i++;
|
|
149
|
+
// Defined, never assigned: a "__proto__" key is an own field like any other, and the prototype stays put.
|
|
150
|
+
Object.defineProperty(out, key, { value: value(depth + 1), enumerable: true, writable: true, configurable: true });
|
|
151
|
+
ws();
|
|
152
|
+
if (text[i] === ",") {
|
|
153
|
+
i++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (text[i] === "}") {
|
|
157
|
+
i++;
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
fail("an object");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (c === "[") {
|
|
164
|
+
i++;
|
|
165
|
+
const out = [];
|
|
166
|
+
ws();
|
|
167
|
+
if (text[i] === "]") {
|
|
168
|
+
i++;
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
for (;;) {
|
|
172
|
+
out.push(value(depth + 1));
|
|
173
|
+
ws();
|
|
174
|
+
if (text[i] === ",") {
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (text[i] === "]") {
|
|
179
|
+
i++;
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
fail("an array");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (c === '"')
|
|
186
|
+
return str();
|
|
187
|
+
const m = /^(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/.exec(text.slice(i));
|
|
188
|
+
if (!m)
|
|
189
|
+
fail("a value");
|
|
190
|
+
i += m[0].length;
|
|
191
|
+
return JSON.parse(m[0]);
|
|
192
|
+
};
|
|
193
|
+
const out = value(0);
|
|
194
|
+
ws();
|
|
195
|
+
if (i !== text.length)
|
|
196
|
+
fail("more than one value");
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/** Whether `given` is `key`, in time that does not depend on where they differ. */
|
|
200
|
+
export const sameKey = (given, key) => {
|
|
201
|
+
const a = createHash("sha256").update(given).digest();
|
|
202
|
+
const b = createHash("sha256").update(key).digest();
|
|
203
|
+
return timingSafeEqual(a, b);
|
|
204
|
+
};
|
|
205
|
+
/** `base` as the proxy joins a route's path to it: its origin and its path, without a trailing slash. */
|
|
206
|
+
function baseOf(base) {
|
|
207
|
+
const url = new URL(base);
|
|
208
|
+
// A backslash is a path separator to the URL parser, so it could move the base to a path the text does not read as.
|
|
209
|
+
if (url.username || url.password || url.search || url.hash || base.includes("\\"))
|
|
210
|
+
throw new Error("agentrun-kernel: a model proxy's base is an origin and a path, with no userinfo, query, fragment or backslash");
|
|
211
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
212
|
+
}
|
|
213
|
+
/** Start a session's model proxy on the loopback interface, admitting `key`. */
|
|
214
|
+
/**
|
|
215
|
+
* The most input tokens a request with body `sent` (the body `apply` returned, as it is sent) can be billed for: its UTF-8
|
|
216
|
+
* byte count, plus the input the policy says the provider adds that the body does not carry. A provider's tokenizer is a
|
|
217
|
+
* byte-level BPE, whose every token covers at least one byte of what it encodes, so the text the body carries can never
|
|
218
|
+
* be more tokens than bytes, however dense (CJK, emoji, base64). It is not an average of characters per token, which
|
|
219
|
+
* undercounts dense text. Undefined, never 0, when the policy says the body has no bound (its undefined) or its figure
|
|
220
|
+
* cannot be read (a throw, or no count, which is reported): the input is then unmeasured.
|
|
221
|
+
*/
|
|
222
|
+
export function inputTokensMaxOf(policy, body, sent, say = () => { }) {
|
|
223
|
+
try {
|
|
224
|
+
// Only a policy with no hook adds nothing. A hook's undefined is its word that the body has no bound: unmeasured,
|
|
225
|
+
// never 0, and no failure to report. Anything else that is not a count (null, a throw) is reported, and unmeasured too.
|
|
226
|
+
const overhead = policy.serverInputOverhead ? policy.serverInputOverhead(body) : 0;
|
|
227
|
+
if (overhead === undefined)
|
|
228
|
+
return undefined;
|
|
229
|
+
if (typeof overhead !== "number" || !Number.isSafeInteger(overhead) || overhead < 0)
|
|
230
|
+
throw new Error(`not a token count: ${String(overhead).slice(0, 40)}`);
|
|
231
|
+
return Buffer.byteLength(sent, "utf8") + overhead;
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
say(`model proxy (${policy.id}): the policy could not say what the provider adds to the body's input; its input is unmeasured`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
export async function startModelProxy(options, key) {
|
|
239
|
+
const { policy, hooks, model } = options;
|
|
240
|
+
if (!key)
|
|
241
|
+
throw new Error("agentrun-kernel: a model proxy needs the session's key");
|
|
242
|
+
const wrongHeaders = hostHeaderProblems(options.headers);
|
|
243
|
+
if (wrongHeaders.length)
|
|
244
|
+
throw new Error(`agentrun-kernel: ${wrongHeaders.join("; ")}`);
|
|
245
|
+
const hostHeaders = { ...options.headers };
|
|
246
|
+
const forward = options.fetch ?? fetch;
|
|
247
|
+
const origin = baseOf(options.origin);
|
|
248
|
+
const refusedStatus = policy.refusedStatus ?? 400;
|
|
249
|
+
const inflight = new Set();
|
|
250
|
+
/** Set by close(): nothing is admitted or sent after it. */
|
|
251
|
+
let closing = false;
|
|
252
|
+
/** The host's log, which must never break a request (a throw from it is dropped). */
|
|
253
|
+
const say = (message, detail) => {
|
|
254
|
+
try {
|
|
255
|
+
options.log?.(message, detail);
|
|
256
|
+
}
|
|
257
|
+
catch { /* the host's log failed; the request goes on */ }
|
|
258
|
+
};
|
|
259
|
+
const refuse = (res, status, message, req) => {
|
|
260
|
+
// Every refusal is reported, so a client that calls something the table does not hold is seen, not silently refused.
|
|
261
|
+
// The client's user-agent names who called: the session's own harness, or another local process that found the port.
|
|
262
|
+
const agent = req?.headers["user-agent"];
|
|
263
|
+
say(`model proxy (${policy.id}): refused`, {
|
|
264
|
+
status, method: String(req?.method ?? "").slice(0, 16), path: String(req?.url ?? "").slice(0, 200), reason: message.slice(0, 200),
|
|
265
|
+
...(typeof agent === "string" && agent ? { userAgent: agent.slice(0, 120) } : {}),
|
|
266
|
+
});
|
|
267
|
+
res.writeHead(status, { "content-type": "application/json", connection: "close" });
|
|
268
|
+
res.end(JSON.stringify({ error: { message, type: "agentrun_proxy" } }));
|
|
269
|
+
};
|
|
270
|
+
const serve = async (req, res) => {
|
|
271
|
+
// A request refused before its body is read is drained, so its client gets the answer instead of a reset.
|
|
272
|
+
res.once("finish", () => { if (!req.complete)
|
|
273
|
+
req.resume(); });
|
|
274
|
+
let route;
|
|
275
|
+
let query;
|
|
276
|
+
try {
|
|
277
|
+
({ route, query } = matchRoute(policy.routes, req.method ?? "", req.url ?? ""));
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
// Without the key every request that is not a local route gets the same answer, so the table can't be mapped.
|
|
281
|
+
if (!sameKey(policy.keyOf(req.headers), key))
|
|
282
|
+
return refuse(res, 401, "this session's key is required", req);
|
|
283
|
+
const e = error;
|
|
284
|
+
return refuse(res, e.status ?? 403, e.message, req);
|
|
285
|
+
}
|
|
286
|
+
// A local route forwards nothing and answers only a status, so it needs no key: a client's connectivity check
|
|
287
|
+
// (the Claude Agent CLI's `HEAD /api/hello`) is sent without one.
|
|
288
|
+
if (route.local !== undefined) {
|
|
289
|
+
res.writeHead(route.local, { "content-length": "0" });
|
|
290
|
+
return res.end();
|
|
291
|
+
}
|
|
292
|
+
if (!sameKey(policy.keyOf(req.headers), key))
|
|
293
|
+
return refuse(res, 401, "this session's key is required", req);
|
|
294
|
+
const chunks = [];
|
|
295
|
+
let size = 0;
|
|
296
|
+
for await (const chunk of req) {
|
|
297
|
+
size += chunk.length;
|
|
298
|
+
if (size > MAX_BODY)
|
|
299
|
+
return refuse(res, 413, "the body is too large", req);
|
|
300
|
+
chunks.push(chunk);
|
|
301
|
+
}
|
|
302
|
+
let body;
|
|
303
|
+
const headers = { "content-type": "application/json" };
|
|
304
|
+
try {
|
|
305
|
+
body = policy.apply(parseStrict(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))), route);
|
|
306
|
+
// The listed headers, as the policy keeps them (or refuses them), before the runner is asked.
|
|
307
|
+
for (const name of route.headers ?? []) {
|
|
308
|
+
const value = req.headers[name.toLowerCase()];
|
|
309
|
+
const kept = typeof value === "string" ? (policy.header ? policy.header(name, value) : value) : undefined;
|
|
310
|
+
if (kept !== undefined)
|
|
311
|
+
headers[name] = kept;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
const e = error;
|
|
316
|
+
return refuse(res, e instanceof ProxyRefusal ? e.status : 400, e instanceof ProxyRefusal ? e.message : "the body was refused", req);
|
|
317
|
+
}
|
|
318
|
+
// The model this request is for, as the policy read it from the body it checked: the runner is asked about it, and
|
|
319
|
+
// it is priced at its rate.
|
|
320
|
+
let asked = model;
|
|
321
|
+
try {
|
|
322
|
+
const named = policy.modelOf?.(body);
|
|
323
|
+
if (typeof named === "string" && named)
|
|
324
|
+
asked = named;
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
say(`model proxy (${policy.id}): the policy could not name the request's model; it is booked as the session's`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
328
|
+
}
|
|
329
|
+
// The body as it is sent, and the most input tokens it can be billed for, measured before the runner is asked.
|
|
330
|
+
let sent;
|
|
331
|
+
try {
|
|
332
|
+
sent = JSON.stringify(body);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
say(`model proxy (${policy.id}): the body could not be serialized`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
336
|
+
return refuse(res, 500, "the body could not be serialized", req);
|
|
337
|
+
}
|
|
338
|
+
let inputTokensMax = inputTokensMaxOf(policy, body, sent, say);
|
|
339
|
+
// What the body asks for at most, and the least it may be limited to, as the policy reads them. One the policy cannot
|
|
340
|
+
// read leaves the request's worst case unknown: its input is unmeasured.
|
|
341
|
+
const figure = (value) => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined);
|
|
342
|
+
let outputTokensAsked;
|
|
343
|
+
let outputTokensLeast;
|
|
344
|
+
try {
|
|
345
|
+
outputTokensAsked = figure(policy.outputAsked?.(body));
|
|
346
|
+
outputTokensLeast = figure(policy.outputLeast?.(body));
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
say(`model proxy (${policy.id}): the policy could not read the body's output; its worst case is unmeasured`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
350
|
+
inputTokensMax = undefined;
|
|
351
|
+
}
|
|
352
|
+
// The provider's credential and its headers come before the runner is asked: a request that could not be sent is
|
|
353
|
+
// refused without being admitted, so it is never booked and spends no turn.
|
|
354
|
+
let credential = "";
|
|
355
|
+
try {
|
|
356
|
+
credential = await options.credential();
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
say(`model proxy (${policy.id}): the provider credential could not be read`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
360
|
+
}
|
|
361
|
+
if (!credential)
|
|
362
|
+
return refuse(res, 500, "the session has no provider credential", req);
|
|
363
|
+
try {
|
|
364
|
+
// The host's own headers, over any the client sent by those names; then the credential's, over everything. A name
|
|
365
|
+
// replaces any other spelling of it, so no two headers of one name reach the provider combined.
|
|
366
|
+
const set = (name, value) => {
|
|
367
|
+
for (const sent of Object.keys(headers))
|
|
368
|
+
if (sent.toLowerCase() === name.toLowerCase())
|
|
369
|
+
delete headers[sent];
|
|
370
|
+
headers[name] = value;
|
|
371
|
+
};
|
|
372
|
+
for (const [name, value] of Object.entries(hostHeaders))
|
|
373
|
+
set(name, value);
|
|
374
|
+
for (const [name, value] of Object.entries(policy.authHeaders(credential)))
|
|
375
|
+
set(name, value);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
say(`model proxy (${policy.id}): the policy could not build the provider's credential headers`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
379
|
+
return refuse(res, 500, "the provider's credential headers could not be built", req);
|
|
380
|
+
}
|
|
381
|
+
if (closing)
|
|
382
|
+
return refuse(res, 503, "the model proxy is closing", req);
|
|
383
|
+
// This request's own admission, handed back to its booking (requests may be in flight together).
|
|
384
|
+
let ticket;
|
|
385
|
+
try {
|
|
386
|
+
if (!route.free)
|
|
387
|
+
ticket = await hooks.beforeRequest({
|
|
388
|
+
model: asked, inputTokensMax,
|
|
389
|
+
...(outputTokensAsked === undefined ? {} : { outputTokensAsked }),
|
|
390
|
+
...(outputTokensLeast === undefined ? {} : { outputTokensLeast }),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
// Not 429: clients retry that, and the runner already refused this request (a turn cap, the host's budget).
|
|
395
|
+
return refuse(res, refusedStatus, `refused before it was sent: ${String(error?.message ?? error).slice(0, 300)}`, req);
|
|
396
|
+
}
|
|
397
|
+
/** An admitted request that is not sent after all: refused to its client, and released, booked at nothing, so the
|
|
398
|
+
* runner's count agrees. */
|
|
399
|
+
const release = async (status, message) => {
|
|
400
|
+
refuse(res, status, message, req);
|
|
401
|
+
if (!route.free) {
|
|
402
|
+
try {
|
|
403
|
+
await hooks.afterRequest({ input: 0, output: 0, cacheRead: 0, usd: 0 }, ticket);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
say(`model proxy (${policy.id}): afterRequest threw; the request was not sent and not booked`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
// Admitted while the proxy was closing, and never sent.
|
|
411
|
+
if (closing)
|
|
412
|
+
return release(503, "the model proxy is closing");
|
|
413
|
+
// Admitted with an output limit (what is left of a spend cap can buy): written into the body, which is never sent
|
|
414
|
+
// unlimited, nor longer than the body its input bound was measured on.
|
|
415
|
+
const limit = typeof ticket === "object" && ticket !== null ? ticket.outputTokensMax : undefined;
|
|
416
|
+
if (limit !== undefined) {
|
|
417
|
+
if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || !policy.limitOutput)
|
|
418
|
+
return release(500, "the request's output could not be limited to what its spend cap leaves");
|
|
419
|
+
let limited;
|
|
420
|
+
try {
|
|
421
|
+
limited = JSON.stringify(policy.limitOutput(body, limit));
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
say(`model proxy (${policy.id}): the policy could not limit the body's output`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
425
|
+
return release(500, "the request's output could not be limited to what its spend cap leaves");
|
|
426
|
+
}
|
|
427
|
+
if (Buffer.byteLength(limited, "utf8") > Buffer.byteLength(sent, "utf8"))
|
|
428
|
+
return release(500, "the request's limited body is longer than the one its input was measured on");
|
|
429
|
+
sent = limited;
|
|
430
|
+
}
|
|
431
|
+
const controller = new AbortController();
|
|
432
|
+
inflight.add(controller);
|
|
433
|
+
/** That the client left before the answer reached it whole (the harness stopped the request). */
|
|
434
|
+
let clientLeft = false;
|
|
435
|
+
/** The bytes of the answer the client was sent. */
|
|
436
|
+
let sentBytes = 0;
|
|
437
|
+
/** That the answer ended because its client left part way, not because the provider or this proxy ended it. */
|
|
438
|
+
let stoppedByClient = false;
|
|
439
|
+
res.on("close", () => {
|
|
440
|
+
if (!answered)
|
|
441
|
+
clientLeft = true;
|
|
442
|
+
controller.abort();
|
|
443
|
+
});
|
|
444
|
+
let head = "";
|
|
445
|
+
let tail = "";
|
|
446
|
+
let length = 0;
|
|
447
|
+
/** The provider's answer, once it reached the client whole. */
|
|
448
|
+
let answered;
|
|
449
|
+
/** That the provider's answer began, with its status: what a client that left before the end had of it. */
|
|
450
|
+
let began;
|
|
451
|
+
let watchEnd;
|
|
452
|
+
/** The ids its headers gave it. */
|
|
453
|
+
let ids;
|
|
454
|
+
let reported;
|
|
455
|
+
try {
|
|
456
|
+
// From here a paid request may be paid, so every way out below books it exactly once.
|
|
457
|
+
const upstream = await forward(`${origin}${route.path}${query ? `?${query}` : ""}`, { method: route.method, headers, body: sent, signal: controller.signal });
|
|
458
|
+
// The provider answered (and a paid request was paid): a host callback that throws must not lose the answer or
|
|
459
|
+
// its booking.
|
|
460
|
+
try {
|
|
461
|
+
options.onResponse?.({ method: route.method, path: route.path, free: route.free === true, status: upstream.status });
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
say(`model proxy (${policy.id}): onResponse threw`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
465
|
+
}
|
|
466
|
+
// Its ids are kept before its body is read: from here the provider has seen it, whatever cuts it off.
|
|
467
|
+
ids = route.free ? undefined : providerIdsOf(upstream.headers);
|
|
468
|
+
reported = route.free ? undefined : reportedCostOf(upstream.headers);
|
|
469
|
+
if (ids && hooks.responded) {
|
|
470
|
+
try {
|
|
471
|
+
await hooks.responded(ids, ticket);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
say(`model proxy (${policy.id}): responded threw; the request's ids are kept with its booking only`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
began = { ok: upstream.ok };
|
|
478
|
+
res.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/json" });
|
|
479
|
+
const decoder = new TextDecoder();
|
|
480
|
+
let watcher;
|
|
481
|
+
try {
|
|
482
|
+
watcher = policy.watch?.();
|
|
483
|
+
watchEnd = watcher?.end?.bind(watcher);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
say(`model proxy (${policy.id}): the response watcher could not start`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
487
|
+
try {
|
|
488
|
+
policy.unwatched?.();
|
|
489
|
+
}
|
|
490
|
+
catch { }
|
|
491
|
+
}
|
|
492
|
+
if (upstream.body) {
|
|
493
|
+
for await (const chunk of upstream.body) {
|
|
494
|
+
// What the watcher reads of a piece, it reads before the client has the piece.
|
|
495
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
496
|
+
if (watcher) {
|
|
497
|
+
try {
|
|
498
|
+
watcher.chunk(text);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
say(`model proxy (${policy.id}): the response watcher failed`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
502
|
+
try {
|
|
503
|
+
watcher.failed?.();
|
|
504
|
+
}
|
|
505
|
+
catch { }
|
|
506
|
+
watcher = undefined;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// A client that reads slowly holds the provider back: nothing more is read until what was written drains.
|
|
510
|
+
if (!res.write(chunk)) {
|
|
511
|
+
// One wait, and nothing left behind: whichever comes first takes both listeners off.
|
|
512
|
+
await new Promise((resolve) => {
|
|
513
|
+
const done = () => { res.off("drain", done); res.off("close", done); resolve(); };
|
|
514
|
+
res.on("drain", done);
|
|
515
|
+
res.on("close", done);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
// The usage is at the start, the end, or both: keep those, not the whole answer.
|
|
519
|
+
length += text.length;
|
|
520
|
+
sentBytes += chunk.byteLength;
|
|
521
|
+
if (head.length < USAGE_WINDOW)
|
|
522
|
+
head += text.slice(0, USAGE_WINDOW - head.length);
|
|
523
|
+
tail = (tail + text).slice(-USAGE_WINDOW);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
res.end();
|
|
527
|
+
answered = { ok: upstream.ok };
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
// Its client had left before this (a stop): read now, as the proxy's own end below closes the response too.
|
|
531
|
+
stoppedByClient = clientLeft && began?.ok === true;
|
|
532
|
+
say(`model proxy (${policy.id}): the request did not complete`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
533
|
+
if (!res.headersSent)
|
|
534
|
+
refuse(res, 502, "the provider did not answer", req);
|
|
535
|
+
else
|
|
536
|
+
res.destroy();
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
inflight.delete(controller);
|
|
540
|
+
try {
|
|
541
|
+
watchEnd?.();
|
|
542
|
+
}
|
|
543
|
+
catch { }
|
|
544
|
+
}
|
|
545
|
+
// Every paid request the proxy forwarded is booked exactly once, after the client has its answer (or lost it):
|
|
546
|
+
// nothing here can turn an answer into an error the client sees and might pay again for. Its usage and price
|
|
547
|
+
// when the answer is whole and says them; nothing for a provider's error answer; an unknown cost for an answer
|
|
548
|
+
// cut short, one that said no usage, or a usage or a price that could not be read. A booking that fails is
|
|
549
|
+
// reported (the runner stops the node on it).
|
|
550
|
+
if (route.free)
|
|
551
|
+
return;
|
|
552
|
+
let usage;
|
|
553
|
+
let usd = null;
|
|
554
|
+
// Whether `usd` is a figure the provider gave (an error answer's nothing included), not one this proxy made up.
|
|
555
|
+
let own = false;
|
|
556
|
+
let tableUsd;
|
|
557
|
+
// A client may leave once it has read the response's usage (Codex does, at the completion event) while the provider
|
|
558
|
+
// still holds the stream open: what was read of a successful answer is read for its usage as a whole one is. With none
|
|
559
|
+
// in it, the cost is unknown, as for any answer cut short.
|
|
560
|
+
const seen = answered ?? (began?.ok ? began : undefined);
|
|
561
|
+
if (!seen) {
|
|
562
|
+
usage = { input: 0, output: 0, cacheRead: 0 };
|
|
563
|
+
}
|
|
564
|
+
else if (!seen.ok) {
|
|
565
|
+
// A provider's error answer was not a paid response: it costs nothing.
|
|
566
|
+
usage = { input: 0, output: 0, cacheRead: 0 };
|
|
567
|
+
usd = 0;
|
|
568
|
+
own = true;
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
let unread = false;
|
|
572
|
+
try {
|
|
573
|
+
usage = policy.usageOf(length <= USAGE_WINDOW ? tail : `${head}\n${tail}`);
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
say(`model proxy (${policy.id}): the usage could not be read; the request is booked at an unknown cost`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
577
|
+
usage = { input: 0, output: 0, cacheRead: 0 };
|
|
578
|
+
unread = true;
|
|
579
|
+
}
|
|
580
|
+
if (!usage) {
|
|
581
|
+
usage = { input: 0, output: 0, cacheRead: 0 };
|
|
582
|
+
unread = true;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
usd = unread ? null : options.price?.(asked, usage) ?? null;
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
say(`model proxy (${policy.id}): the price could not be computed; the request is booked at an unknown cost`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
589
|
+
usd = null;
|
|
590
|
+
}
|
|
591
|
+
// What the gateway says it cost is what was paid; the table's figure stays beside it, to audit the table.
|
|
592
|
+
if (reported !== undefined) {
|
|
593
|
+
tableUsd = usd;
|
|
594
|
+
usd = reported;
|
|
595
|
+
own = true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
// A response its client stopped and whose cost is unknown says what streamed of it, for the host to count it by.
|
|
600
|
+
const stopped = stoppedByClient && usd === null ? { stoppedAfterBytes: sentBytes } : {};
|
|
601
|
+
await hooks.afterRequest({ ...usage, usd, ...(own ? {} : { estimated: true }), ...(tableUsd === undefined ? {} : { tableUsd }), ...(ids ? { providerIds: ids } : {}), ...stopped }, ticket);
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
say(`model proxy (${policy.id}): afterRequest threw; the request was answered and not booked`, { error: String(error?.message ?? error).slice(0, 200) });
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const server = createServer((req, res) => { void serve(req, res).catch(() => { if (!res.headersSent)
|
|
608
|
+
refuse(res, 500, "the proxy failed", req);
|
|
609
|
+
else
|
|
610
|
+
res.destroy(); }); });
|
|
611
|
+
await new Promise((resolve, reject) => {
|
|
612
|
+
server.once("error", reject);
|
|
613
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
614
|
+
});
|
|
615
|
+
const address = server.address();
|
|
616
|
+
if (!address || typeof address === "string")
|
|
617
|
+
throw new Error("agentrun-kernel: the model proxy has no port");
|
|
618
|
+
return {
|
|
619
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
620
|
+
key,
|
|
621
|
+
close: async () => {
|
|
622
|
+
closing = true;
|
|
623
|
+
for (const c of inflight)
|
|
624
|
+
c.abort();
|
|
625
|
+
server.closeAllConnections();
|
|
626
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** `writer` with every read and append run one at a time, in call order. A failed one rejects for its caller only. */
|
|
2
|
+
export function serialWriter(writer) {
|
|
3
|
+
let chain = Promise.resolve();
|
|
4
|
+
const next = (work) => {
|
|
5
|
+
const run = chain.then(work);
|
|
6
|
+
chain = run.catch(() => undefined);
|
|
7
|
+
return run;
|
|
8
|
+
};
|
|
9
|
+
return { key: writer.key, read: () => next(() => writer.read()), append: (records) => next(() => writer.append(records)) };
|
|
10
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createHarnessRunner, modelPortFor, SUBMIT_TOOL, RUNNER_RECORDS } from "./runner.js";
|
|
2
|
+
export type { Admission, BeforeRequest, HarnessRunner, HarnessRunnerOptions, InFlight, ModelBooking, ModelPortEvent, ModelPortOptions } from "./runner.js";
|
|
3
|
+
export { DELEGATE_RESULT_SCHEMA, DELEGATE_TOOL, delegateProblems, delegateTool } from "./delegate.js";
|
|
4
|
+
export type { DelegateAgent, DelegateEvent, DelegateOptions } from "./delegate.js";
|
|
5
|
+
export { HarnessRunError, SessionFenced } from "./errors.js";
|
|
6
|
+
export { toolSchemaProblems } from "./schema-check.js";
|
|
7
|
+
export { READ_SKILL_FILE, readSkillFileTool, SKILL_BYTES, skillProblems } from "./skill-files.js";
|
|
8
|
+
export type { HostSkill, SkillFileList } from "./skill-files.js";
|
|
9
|
+
export type { HarnessRunReason } from "./errors.js";
|
|
10
|
+
export { HOST_RESULT_MAX_CHARS } from "./types.js";
|
|
11
|
+
export type { AdapterCapabilities, AdapterEvent, AdapterSession, AgentAdapter, HarnessEvent, HostTool, ToolImage, JsonValue, ModelPort, ModelTicket, ModelRequest, ModelUsage, NodeRequest, SessionIO, SessionRecord, SessionSpec, SessionState, SessionStore, SessionWriter, TurnStatus, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createHarnessRunner, modelPortFor, SUBMIT_TOOL, RUNNER_RECORDS } from "./runner.js";
|
|
2
|
+
export { DELEGATE_RESULT_SCHEMA, DELEGATE_TOOL, delegateProblems, delegateTool } from "./delegate.js";
|
|
3
|
+
export { HarnessRunError, SessionFenced } from "./errors.js";
|
|
4
|
+
export { toolSchemaProblems } from "./schema-check.js";
|
|
5
|
+
export { READ_SKILL_FILE, readSkillFileTool, SKILL_BYTES, skillProblems } from "./skill-files.js";
|
|
6
|
+
export { HOST_RESULT_MAX_CHARS } from "./types.js";
|