premanmcp 1.1.15 → 1.1.16
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/bin/eval.js +16 -5
- package/bin/eval_harness.py +83 -1
- package/bin/eval_templates.js +37 -4
- package/bin/runner.js +2 -1
- package/bin/tool_proxy.py +218 -17
- package/package.json +1 -1
package/bin/eval.js
CHANGED
|
@@ -379,6 +379,15 @@ export function tracesInProcess(target) {
|
|
|
379
379
|
return target?.kind === "callable" && target.language === "python" && IN_PROCESS_SHAPES.has(target.shape || "");
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
/** Shapes whose agent runs in its own process, so the harness needs nothing of the customer's. */
|
|
383
|
+
const OUT_OF_PROCESS_SHAPES = new Set(["node_bridge", "cli_subprocess"]);
|
|
384
|
+
|
|
385
|
+
/** Record, for the job runner, whether this target's agent is imported into the harness. */
|
|
386
|
+
export function markAgentProcess(target) {
|
|
387
|
+
process.env.PREMAN_EVAL_AGENT_IN_PROCESS =
|
|
388
|
+
target?.kind === "callable" && OUT_OF_PROCESS_SHAPES.has(target.shape || "") ? "0" : "1";
|
|
389
|
+
}
|
|
390
|
+
|
|
382
391
|
/** How long a first resolve may take before it counts as a failure. */
|
|
383
392
|
const COLD_RESOLVE_MS = 600_000;
|
|
384
393
|
|
|
@@ -1268,12 +1277,13 @@ export async function executeEvalRun(
|
|
|
1268
1277
|
home = suiteHome(job);
|
|
1269
1278
|
pruneRuns(home, job);
|
|
1270
1279
|
laid = materialize(job, home, { trace: process.env.PREMAN_EVAL_TRACE === "1" });
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
// PreMan's
|
|
1280
|
+
// Only an agent imported into the harness needs the customer's environment:
|
|
1281
|
+
// it must resolve against *their* dependencies, and PreMan's bundle has
|
|
1282
|
+
// assert-ai and nothing of theirs. A node bridge or a command runs the agent
|
|
1283
|
+
// in its own process, so PreMan's runtime is enough there.
|
|
1284
|
+
const agentInProcess = process.env.PREMAN_EVAL_AGENT_IN_PROCESS !== "0";
|
|
1275
1285
|
const runtime =
|
|
1276
|
-
String(job.target_kind || "") === "callable"
|
|
1286
|
+
String(job.target_kind || "") === "callable" && agentInProcess
|
|
1277
1287
|
? ""
|
|
1278
1288
|
: await ensureRuntime({ log, download, progress: progressLine });
|
|
1279
1289
|
python = resolveInterpreter({ projectPath, log, runtime });
|
|
@@ -1991,6 +2001,7 @@ async function runWithToken(
|
|
|
1991
2001
|
// import path, so the harness reads this and inserts it before handing off.
|
|
1992
2002
|
process.env.PREMAN_EVAL_SYS_PATH = target.sysPath;
|
|
1993
2003
|
}
|
|
2004
|
+
markAgentProcess(target);
|
|
1994
2005
|
// On unless PREMAN_EVAL_TRACE=0 (docs/EVAL_AGENT_TRACING.md). Kept only for an
|
|
1995
2006
|
// agent that runs inside the harness's Python process; a node bridge or a
|
|
1996
2007
|
// subprocess is invisible to in-process tracing, and tracing it would only
|
package/bin/eval_harness.py
CHANGED
|
@@ -309,11 +309,19 @@ def _turn_raw(ref, raw_result):
|
|
|
309
309
|
|
|
310
310
|
Carried on ``raw`` because assert-ai stores it verbatim and owns every other
|
|
311
311
|
field of the artifact; the backend reads it back per turn
|
|
312
|
-
(docs/superpowers/specs/2026-09-30-tool-call-capture-design.md §7.2).
|
|
312
|
+
(docs/superpowers/specs/2026-09-30-tool-call-capture-design.md §7.2). A
|
|
313
|
+
proxied agent's token counts become model-call nodes here, as spans would
|
|
314
|
+
(docs/superpowers/specs/2026-10-08-proxy-tokens-ts-design.md §5).
|
|
313
315
|
"""
|
|
314
316
|
raw = {"callable": ref}
|
|
315
317
|
capture = raw_result.get("tool_capture")
|
|
316
318
|
if isinstance(capture, dict):
|
|
319
|
+
try:
|
|
320
|
+
built = _proxy_nodes(capture)
|
|
321
|
+
if built is not None:
|
|
322
|
+
_add_nodes(raw, *built)
|
|
323
|
+
except Exception:
|
|
324
|
+
capture.pop("model_calls", None)
|
|
317
325
|
raw["tool_capture"] = _session._sanitize_endpoint_value(capture)
|
|
318
326
|
return raw
|
|
319
327
|
|
|
@@ -334,6 +342,79 @@ def _attach_capture(result, payload):
|
|
|
334
342
|
return
|
|
335
343
|
|
|
336
344
|
|
|
345
|
+
def _span_tokens_in(events) -> bool:
|
|
346
|
+
return any(isinstance(e, dict) and isinstance((e.get("raw") or {}).get("_tokens"), dict) for e in events or [])
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _proxy_nodes(capture, spans_have_tokens=False):
|
|
350
|
+
"""The proxy's token records for one turn, as the model-call nodes spans would give.
|
|
351
|
+
|
|
352
|
+
Pops ``model_calls`` so the stored capture never carries them. All or
|
|
353
|
+
nothing: one call without counts leaves the turn with none and
|
|
354
|
+
``tokens_missing`` saying why, because a partial sum reads as a complete
|
|
355
|
+
number. Returns (nodes, totals) or None.
|
|
356
|
+
"""
|
|
357
|
+
if not isinstance(capture, dict):
|
|
358
|
+
return None
|
|
359
|
+
calls = capture.pop("model_calls", None)
|
|
360
|
+
if not calls or spans_have_tokens:
|
|
361
|
+
return None
|
|
362
|
+
if not isinstance(calls, list) or not all(isinstance(c, dict) for c in calls):
|
|
363
|
+
capture["tokens_missing"] = "a model call's token counts could not be read"
|
|
364
|
+
return None
|
|
365
|
+
gap = next((c for c in calls if not (isinstance(c.get("input"), int) and isinstance(c.get("output"), int))), None)
|
|
366
|
+
if gap is not None:
|
|
367
|
+
capture["tokens_missing"] = gap.get("missing") or "a model call's token counts could not be read"
|
|
368
|
+
return None
|
|
369
|
+
nodes = [
|
|
370
|
+
{
|
|
371
|
+
"view": ["target", "combined"],
|
|
372
|
+
"actor": "target",
|
|
373
|
+
"edit": {"type": "add_message", "message": {"role": "assistant", "content": ""}},
|
|
374
|
+
"raw": {
|
|
375
|
+
"_node": c.get("kind") or "model",
|
|
376
|
+
"_model": c.get("model") or "",
|
|
377
|
+
"_tokens": {"input": c["input"], "output": c["output"]},
|
|
378
|
+
"_latency_ms": c.get("ms"),
|
|
379
|
+
"_source": "proxy",
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
for c in calls
|
|
383
|
+
]
|
|
384
|
+
totals = {
|
|
385
|
+
"total_tokens": {"input": sum(c["input"] for c in calls), "output": sum(c["output"] for c in calls)},
|
|
386
|
+
"total_latency_ms": round(sum(c["ms"] for c in calls if isinstance(c.get("ms"), (int, float))), 1),
|
|
387
|
+
"llm_call_count": len(calls),
|
|
388
|
+
}
|
|
389
|
+
return nodes, totals
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _add_nodes(raw, nodes, totals):
|
|
393
|
+
raw["trace_events"] = list(raw.get("trace_events") or []) + nodes
|
|
394
|
+
meta = dict(raw.get("trace_metadata") or {"nodes_visited": [], "tools_called": []})
|
|
395
|
+
meta.update(totals)
|
|
396
|
+
raw["trace_metadata"] = meta
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _attach_proxy_nodes(result, payload):
|
|
400
|
+
"""Token nodes for a traced turn whose spans carried no tokens (a proxied agent)."""
|
|
401
|
+
capture = payload.get("tool_capture") if isinstance(payload, dict) else None
|
|
402
|
+
if not isinstance(capture, dict):
|
|
403
|
+
return
|
|
404
|
+
try:
|
|
405
|
+
raw = result.raw if isinstance(result.raw, dict) else None
|
|
406
|
+
built = _proxy_nodes(capture, spans_have_tokens=_span_tokens_in((raw or {}).get("trace_events")))
|
|
407
|
+
if built is None or raw is None:
|
|
408
|
+
return
|
|
409
|
+
_add_nodes(raw, *built)
|
|
410
|
+
for message in reversed(result.interaction_messages):
|
|
411
|
+
if message.get("role") == "assistant" and isinstance(message.get("raw"), dict):
|
|
412
|
+
_add_nodes(message["raw"], *built)
|
|
413
|
+
return
|
|
414
|
+
except Exception:
|
|
415
|
+
capture.pop("model_calls", None) # never fail the turn, never store the raw records
|
|
416
|
+
|
|
417
|
+
|
|
337
418
|
async def _callable_run_turn(self, messages: list[Message]) -> TurnResult:
|
|
338
419
|
user_text = ""
|
|
339
420
|
for msg in reversed(messages):
|
|
@@ -632,6 +713,7 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
|
|
|
632
713
|
_use_span_trace(payload, holder)
|
|
633
714
|
except Exception:
|
|
634
715
|
pass # a trace that cannot be chosen must never fail the turn
|
|
716
|
+
_attach_proxy_nodes(result, payload)
|
|
635
717
|
_attach_capture(result, payload)
|
|
636
718
|
if payload is None or any(m.get("role") == "tool" for m in result.interaction_messages):
|
|
637
719
|
return result # nothing reported, or the spans already carry the tools
|
package/bin/eval_templates.js
CHANGED
|
@@ -878,10 +878,18 @@ def _runtime() -> str:
|
|
|
878
878
|
PATH, and the wrapper died inside subprocess with a FileNotFoundError that
|
|
879
879
|
read as a broken wrapper rather than a missing tool. Current Node strips
|
|
880
880
|
TypeScript types by itself, so falling back to "node" is usually right and
|
|
881
|
-
is always better than a traceback.
|
|
881
|
+
is always better than a traceback. A repo-local runtime in node_modules/.bin
|
|
882
|
+
wins over PATH.
|
|
882
883
|
"""
|
|
883
884
|
for name in (_WANTED, "node"):
|
|
884
|
-
if
|
|
885
|
+
if not name:
|
|
886
|
+
continue
|
|
887
|
+
# A repo's own runtime first: "npm install" puts tsx there, not on PATH.
|
|
888
|
+
# which() honours PATHEXT, so Windows gets tsx.cmd rather than the shell script beside it.
|
|
889
|
+
local = shutil.which(name, path=str(_REPO / "node_modules" / ".bin"))
|
|
890
|
+
if local:
|
|
891
|
+
return local
|
|
892
|
+
if shutil.which(name):
|
|
885
893
|
return name
|
|
886
894
|
raise RuntimeError(
|
|
887
895
|
"no JavaScript runtime found: tried %r and 'node'. Install Node, or "
|
|
@@ -999,7 +1007,32 @@ console.log = (...args) => process.stderr.write(args.join(" ") + "\n");
|
|
|
999
1007
|
|
|
1000
1008
|
const mod = await import(%%NODE_ENTRY%%);
|
|
1001
1009
|
const agent = mod[%%ENTRY_SYMBOL%%] ?? mod.default;
|
|
1002
|
-
|
|
1010
|
+
|
|
1011
|
+
// The text of a LangChain / LangGraph message list's last message.
|
|
1012
|
+
function lastText(messages) {
|
|
1013
|
+
const last = Array.isArray(messages) ? messages[messages.length - 1] : undefined;
|
|
1014
|
+
const content = last?.content;
|
|
1015
|
+
if (typeof content === "string") return content;
|
|
1016
|
+
if (Array.isArray(content)) {
|
|
1017
|
+
return content.map((part) => (typeof part === "string" ? part : part?.type === "text" ? part.text ?? "" : "")).join("");
|
|
1018
|
+
}
|
|
1019
|
+
return JSON.stringify(last ?? null);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// A function export is called as before. A LangChain/LangGraph agent or graph is
|
|
1023
|
+
// an object with invoke(); a Vercel AI SDK ToolLoopAgent has generate(), which
|
|
1024
|
+
// takes messages or prompt, never both.
|
|
1025
|
+
const turn =
|
|
1026
|
+
typeof agent === "function"
|
|
1027
|
+
? (message, history) => agent(message, history)
|
|
1028
|
+
: typeof agent?.invoke === "function"
|
|
1029
|
+
? async (message, history) =>
|
|
1030
|
+
lastText((await agent.invoke({ messages: [...history, { role: "user", content: message }] }))?.messages)
|
|
1031
|
+
: typeof agent?.generate === "function"
|
|
1032
|
+
? async (message, history) =>
|
|
1033
|
+
(await agent.generate({ messages: [...history, { role: "user", content: message }] }))?.text ?? ""
|
|
1034
|
+
: null;
|
|
1035
|
+
if (turn === null) {
|
|
1003
1036
|
out(JSON.stringify({ error: %%ENTRY_SYMBOL%% + " is not a function in " + %%NODE_ENTRY%% }) + "\n");
|
|
1004
1037
|
process.exit(1);
|
|
1005
1038
|
}
|
|
@@ -1008,7 +1041,7 @@ for await (const line of createInterface({ input: process.stdin })) {
|
|
|
1008
1041
|
if (!line.trim()) continue;
|
|
1009
1042
|
try {
|
|
1010
1043
|
const { message, history } = JSON.parse(line);
|
|
1011
|
-
const reply = await
|
|
1044
|
+
const reply = await turn(message, history ?? []);
|
|
1012
1045
|
const text = typeof reply === "string" ? reply : (reply?.response ?? JSON.stringify(reply));
|
|
1013
1046
|
out(JSON.stringify({ response: text, events: reply?.events ?? [] }) + "\n");
|
|
1014
1047
|
} catch (error) {
|
package/bin/runner.js
CHANGED
|
@@ -38,7 +38,7 @@ import os from "node:os";
|
|
|
38
38
|
import path from "node:path";
|
|
39
39
|
import { fileURLToPath } from "node:url";
|
|
40
40
|
|
|
41
|
-
import { executeEvalRun } from "./eval.js";
|
|
41
|
+
import { executeEvalRun, markAgentProcess } from "./eval.js";
|
|
42
42
|
import { discoverTarget, startAdapter, stepFeed } from "./eval_target.js";
|
|
43
43
|
import { resolveEvalSurface } from "./link.js";
|
|
44
44
|
import { CREDENTIALS_DIR, backendUrl, callBackendJson, cliInvocation, makeArgs, nowMs, onPath, openUrl, packageVersion, resolveApiKey } from "./shared.js";
|
|
@@ -746,6 +746,7 @@ async function serveEvalTarget(args, { log }) {
|
|
|
746
746
|
log(`no agent to evaluate on this machine (${error.message}); eval runs will not be offered`);
|
|
747
747
|
return null;
|
|
748
748
|
}
|
|
749
|
+
markAgentProcess(target);
|
|
749
750
|
|
|
750
751
|
try {
|
|
751
752
|
const adapter = await startAdapter(target, args, {
|
package/bin/tool_proxy.py
CHANGED
|
@@ -18,6 +18,7 @@ import json
|
|
|
18
18
|
import os
|
|
19
19
|
import secrets
|
|
20
20
|
import threading
|
|
21
|
+
import time
|
|
21
22
|
import urllib.error
|
|
22
23
|
import urllib.request
|
|
23
24
|
import zlib
|
|
@@ -84,6 +85,17 @@ _NO_TOOLS_OFFERED = (
|
|
|
84
85
|
"(for example, tools named in plain text)"
|
|
85
86
|
)
|
|
86
87
|
_UNSEEN = (_NO_TRAFFIC, _CHANNEL_CLOSED, _NO_TOOLS_OFFERED)
|
|
88
|
+
_UNREAD_PATHS = "the agent's model requests went to %s, which PreMan does not read"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _unread(paths):
|
|
92
|
+
shown = sorted(set(paths))
|
|
93
|
+
return _UNREAD_PATHS % ", ".join(shown[:3] + (["…"] if len(shown) > 3 else []))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _unseen(reason):
|
|
97
|
+
"""The proxy saw nothing of the turn it could read, so the agent's own report decides."""
|
|
98
|
+
return reason in _UNSEEN or str(reason or "").startswith(_UNREAD_PATHS.split("%s")[0])
|
|
87
99
|
|
|
88
100
|
|
|
89
101
|
def _call(out, cid, name, args):
|
|
@@ -300,6 +312,93 @@ def _decoded(body: bytes, encoding: str) -> bytes:
|
|
|
300
312
|
raise _Unreadable("a model reply was compressed with %s" % encoding)
|
|
301
313
|
|
|
302
314
|
|
|
315
|
+
_TOO_BIG = "a model reply was larger than 2 MB, so it was forwarded without being read"
|
|
316
|
+
_USAGE_NOT_ASKED = (
|
|
317
|
+
"the reply was streamed without token counts; the agent can add "
|
|
318
|
+
"stream_options: {include_usage: true} to its request"
|
|
319
|
+
)
|
|
320
|
+
_USAGE_ENDED_EARLY = "the streamed reply ended without its token counts"
|
|
321
|
+
_USAGE_ABSENT = "the reply carried no token counts"
|
|
322
|
+
_USAGE_CUT = "a model reply was cut off before it ended"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _int(value):
|
|
326
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _counts(kind, usage):
|
|
330
|
+
"""(input, output) from one usage object, or None when either is absent.
|
|
331
|
+
|
|
332
|
+
Anthropic input adds both cache fields, as the OpenInference Anthropic plugin
|
|
333
|
+
does, so a proxied agent's numbers read like a traced Python agent's.
|
|
334
|
+
"""
|
|
335
|
+
if not isinstance(usage, dict):
|
|
336
|
+
return None
|
|
337
|
+
if kind == "openai_chat":
|
|
338
|
+
tokens_in, tokens_out = _int(usage.get("prompt_tokens")), _int(usage.get("completion_tokens"))
|
|
339
|
+
else:
|
|
340
|
+
tokens_in, tokens_out = _int(usage.get("input_tokens")), _int(usage.get("output_tokens"))
|
|
341
|
+
if kind == "anthropic" and tokens_in is not None:
|
|
342
|
+
tokens_in += (_int(usage.get("cache_creation_input_tokens")) or 0) + (
|
|
343
|
+
_int(usage.get("cache_read_input_tokens")) or 0
|
|
344
|
+
)
|
|
345
|
+
if tokens_in is None or tokens_out is None:
|
|
346
|
+
return None
|
|
347
|
+
return tokens_in, tokens_out
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _usage_in_reply(kind, reply):
|
|
351
|
+
"""(model, counts) from a whole JSON reply."""
|
|
352
|
+
return str(reply.get("model") or ""), _counts(kind, reply.get("usage"))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _usage_in_stream(kind, raw: bytes):
|
|
356
|
+
"""(model, counts) from a streamed reply.
|
|
357
|
+
|
|
358
|
+
Anthropic's message_delta usage holds running totals, so a later value
|
|
359
|
+
replaces an earlier one; nothing is added across events.
|
|
360
|
+
"""
|
|
361
|
+
model, usage = "", None
|
|
362
|
+
if kind == "openai_chat":
|
|
363
|
+
for event in _sse(raw):
|
|
364
|
+
model = event.get("model") or model
|
|
365
|
+
if isinstance(event.get("usage"), dict):
|
|
366
|
+
usage = event["usage"]
|
|
367
|
+
elif kind == "openai_responses":
|
|
368
|
+
for event in _sse(raw):
|
|
369
|
+
response = event.get("response")
|
|
370
|
+
if isinstance(response, dict):
|
|
371
|
+
model = response.get("model") or model
|
|
372
|
+
if event.get("type") == "response.completed":
|
|
373
|
+
usage = response.get("usage")
|
|
374
|
+
elif kind == "anthropic":
|
|
375
|
+
usage = {}
|
|
376
|
+
for event in _sse(raw):
|
|
377
|
+
if event.get("type") == "message_start":
|
|
378
|
+
message = event.get("message") or {}
|
|
379
|
+
model = message.get("model") or model
|
|
380
|
+
source = message.get("usage")
|
|
381
|
+
elif event.get("type") == "message_delta":
|
|
382
|
+
source = event.get("usage")
|
|
383
|
+
else:
|
|
384
|
+
continue
|
|
385
|
+
if isinstance(source, dict):
|
|
386
|
+
usage.update({key: value for key, value in source.items() if value is not None})
|
|
387
|
+
return str(model or ""), _counts(kind, usage)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _request_facts(body: bytes):
|
|
391
|
+
"""The request's model, and whether it asked for streamed token counts."""
|
|
392
|
+
try:
|
|
393
|
+
request = json.loads(body or b"{}")
|
|
394
|
+
except ValueError:
|
|
395
|
+
return "", False
|
|
396
|
+
if not isinstance(request, dict):
|
|
397
|
+
return "", False
|
|
398
|
+
options = request.get("stream_options")
|
|
399
|
+
return str(request.get("model") or ""), bool(isinstance(options, dict) and options.get("include_usage"))
|
|
400
|
+
|
|
401
|
+
|
|
303
402
|
def _not_visible(reason):
|
|
304
403
|
return {"state": "not_visible", "source": "proxy", "watched": [], "missing": [], "reason": reason}
|
|
305
404
|
|
|
@@ -313,12 +412,15 @@ class _Turn:
|
|
|
313
412
|
self.results = {} # id -> content
|
|
314
413
|
self.asked = [] # ids the model asked for in a reply during this turn
|
|
315
414
|
self.model_requests = 0
|
|
415
|
+
self.other_paths = [] # POSTs to paths the proxy does not read
|
|
316
416
|
self.problem = ""
|
|
317
417
|
self.reused_id = False # a reply asked for an id an earlier turn already reported
|
|
318
418
|
self.reused_in_turn = False # a reply asked for an id already seen in this turn
|
|
319
419
|
self.offered_tools = False # some model request in the turn offered the model tools
|
|
320
420
|
self.last_reply_failed = False # the most recent model reply was an HTTP error
|
|
321
421
|
self.unrecorded = [] # keys of tool items PreMan cannot record, filed as reported at the end
|
|
422
|
+
self.model_calls = [] # one per model reply read this turn: token counts or why they are missing
|
|
423
|
+
self.in_flight = 0 # model requests forwarded this turn whose reply has not been read yet
|
|
322
424
|
self.already = already if already is not None else set()
|
|
323
425
|
# Ids asked for in an earlier turn of this channel whose result never
|
|
324
426
|
# went back, with their (name, args): a result for one arriving now
|
|
@@ -345,18 +447,62 @@ class _Turn:
|
|
|
345
447
|
with self._lock:
|
|
346
448
|
self.problem = self.problem or reason
|
|
347
449
|
|
|
450
|
+
def _model_call(self, kind, model, counts, ms, missing):
|
|
451
|
+
self.model_calls.append({
|
|
452
|
+
"kind": kind,
|
|
453
|
+
"model": model,
|
|
454
|
+
"input": counts[0] if counts else None,
|
|
455
|
+
"output": counts[1] if counts else None,
|
|
456
|
+
"ms": round(float(ms), 1) if isinstance(ms, (int, float)) else None,
|
|
457
|
+
"missing": None if counts else missing,
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
def _saw_tokens(self, kind, raw, reply, streamed, ms, cut, request_model, asked_usage):
|
|
461
|
+
if cut:
|
|
462
|
+
return self._model_call(kind, request_model, None, ms, _USAGE_CUT)
|
|
463
|
+
try:
|
|
464
|
+
model, counts = _usage_in_stream(kind, raw) if streamed else _usage_in_reply(kind, reply)
|
|
465
|
+
except _Unreadable as exc:
|
|
466
|
+
return self._model_call(kind, request_model, None, ms, str(exc))
|
|
467
|
+
except Exception:
|
|
468
|
+
return self._model_call(kind, request_model, None, ms, "a model reply's token counts could not be read")
|
|
469
|
+
missing = None
|
|
470
|
+
if counts is None:
|
|
471
|
+
if streamed and kind == "openai_chat":
|
|
472
|
+
missing = _USAGE_ENDED_EARLY if asked_usage else _USAGE_NOT_ASKED
|
|
473
|
+
else:
|
|
474
|
+
missing = _USAGE_ABSENT
|
|
475
|
+
self._model_call(kind, model or request_model, counts, ms, missing)
|
|
476
|
+
|
|
348
477
|
def saw_request(self, kind, body):
|
|
349
478
|
with self._lock:
|
|
350
479
|
return self._saw_request(kind, body)
|
|
351
480
|
|
|
352
|
-
def
|
|
481
|
+
def request_started(self):
|
|
482
|
+
with self._lock:
|
|
483
|
+
self.in_flight += 1
|
|
484
|
+
|
|
485
|
+
def request_ended(self):
|
|
486
|
+
with self._lock:
|
|
487
|
+
self.in_flight = max(0, self.in_flight - 1)
|
|
488
|
+
|
|
489
|
+
def saw_other(self, path):
|
|
353
490
|
with self._lock:
|
|
354
|
-
|
|
491
|
+
self.other_paths.append(path)
|
|
492
|
+
|
|
493
|
+
def saw_reply(self, kind, status, content_type, content_encoding, body, truncated, conv=None,
|
|
494
|
+
*, ms=None, cut=False, request_model="", asked_usage=False):
|
|
495
|
+
with self._lock:
|
|
496
|
+
return self._saw_reply(kind, status, content_type, content_encoding, body, truncated, conv,
|
|
497
|
+
ms=ms, cut=cut, request_model=request_model, asked_usage=asked_usage)
|
|
355
498
|
|
|
356
499
|
def outcome(self):
|
|
357
500
|
with self._lock:
|
|
358
501
|
events, capture = self._outcome()
|
|
359
502
|
capture["trace"] = self._trace(self._counted)
|
|
503
|
+
if self.in_flight > 0:
|
|
504
|
+
self._model_call("", "", None, None, "a model reply was still arriving when the turn ended")
|
|
505
|
+
capture["model_calls"] = list(self.model_calls)
|
|
360
506
|
return events, capture
|
|
361
507
|
|
|
362
508
|
def _conversation(self, referenced):
|
|
@@ -447,20 +593,35 @@ class _Turn:
|
|
|
447
593
|
self.unrecorded.append("item:" + key)
|
|
448
594
|
self._fail(reason)
|
|
449
595
|
|
|
450
|
-
def _saw_reply(self, kind, status, content_type, content_encoding, body, truncated, conv=None
|
|
596
|
+
def _saw_reply(self, kind, status, content_type, content_encoding, body, truncated, conv=None,
|
|
597
|
+
*, ms=None, cut=False, request_model="", asked_usage=False):
|
|
598
|
+
self.in_flight = max(0, self.in_flight - 1)
|
|
451
599
|
if status >= 400:
|
|
452
600
|
self.last_reply_failed = True # asks for no tool; cleared if a retry succeeds
|
|
453
601
|
return
|
|
454
602
|
self.last_reply_failed = False
|
|
455
603
|
if truncated:
|
|
456
|
-
|
|
604
|
+
self._model_call(kind, request_model, None, ms, _TOO_BIG)
|
|
605
|
+
return self._fail(_TOO_BIG)
|
|
606
|
+
streamed = "text/event-stream" in (content_type or "")
|
|
607
|
+
reply = None
|
|
457
608
|
try:
|
|
458
609
|
raw = _decoded(body, content_encoding)
|
|
459
|
-
if
|
|
610
|
+
if not streamed:
|
|
611
|
+
reply = json.loads(raw or b"{}")
|
|
612
|
+
except _Unreadable as exc:
|
|
613
|
+
self._model_call(kind, request_model, None, ms, str(exc))
|
|
614
|
+
return self._fail(str(exc))
|
|
615
|
+
except (ValueError, OSError, zlib.error):
|
|
616
|
+
self._model_call(kind, request_model, None, ms, "a model reply could not be read")
|
|
617
|
+
return self._fail("a model reply could not be read")
|
|
618
|
+
self._saw_tokens(kind, raw, reply if isinstance(reply, dict) else {}, streamed, ms, cut,
|
|
619
|
+
request_model, asked_usage)
|
|
620
|
+
try:
|
|
621
|
+
if streamed:
|
|
460
622
|
calls = _requests_in_stream(kind, raw)
|
|
461
623
|
unrecorded = _unrecorded_in_stream(kind, raw)
|
|
462
624
|
else:
|
|
463
|
-
reply = json.loads(raw or b"{}")
|
|
464
625
|
calls = _requests_in_reply(kind, reply) if isinstance(reply, dict) else []
|
|
465
626
|
unrecorded = _unrecorded_in_reply(kind, reply) if isinstance(reply, dict) else []
|
|
466
627
|
except _Unreadable as exc:
|
|
@@ -529,7 +690,7 @@ class _Turn:
|
|
|
529
690
|
if orphan_results:
|
|
530
691
|
return events, _not_visible("a tool result went back to the model for a call PreMan could not read")
|
|
531
692
|
if not self.model_requests:
|
|
532
|
-
return events, _not_visible(_NO_TRAFFIC)
|
|
693
|
+
return events, _not_visible(_unread(self.other_paths) if self.other_paths else _NO_TRAFFIC)
|
|
533
694
|
if self.last_reply_failed:
|
|
534
695
|
return events, _not_visible(
|
|
535
696
|
"the model's last reply in this turn was an HTTP error, so the agent may have answered "
|
|
@@ -547,6 +708,24 @@ class _Turn:
|
|
|
547
708
|
|
|
548
709
|
DEFAULT_UPSTREAM = {"openai": "https://api.openai.com/v1", "anthropic": "https://api.anthropic.com"}
|
|
549
710
|
ENV_VAR = {"openai": "OPENAI_BASE_URL", "anthropic": "ANTHROPIC_BASE_URL"}
|
|
711
|
+
# Names LangChain.js reads before ENV_VAR's: OPENAI_API_BASE (@langchain/openai
|
|
712
|
+
# chat_models/base.js:234) and ANTHROPIC_API_URL (@langchain/anthropic
|
|
713
|
+
# chat_models.js:670). Each gets its own path and upstream, so every client keeps
|
|
714
|
+
# the address it would have used: the official SDKs read only ENV_VAR's names.
|
|
715
|
+
ALIASES = {"openai-api-base": ("openai", "OPENAI_API_BASE"), "anthropic-api-url": ("anthropic", "ANTHROPIC_API_URL")}
|
|
716
|
+
ROUTES = {**{provider: (provider, var) for provider, var in ENV_VAR.items()}, **ALIASES} # path segment -> (provider, variable)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _upstreams(given=None):
|
|
720
|
+
"""Where each route forwards: its own variable, else (an alias) the provider's
|
|
721
|
+
ENV_VAR, which is what LangChain falls back to, else the provider's default.
|
|
722
|
+
A ``given`` map keyed by provider fills the alias routes of that provider too."""
|
|
723
|
+
if given:
|
|
724
|
+
return {**{route: given[provider] for route, (provider, _) in ROUTES.items() if provider in given}, **given}
|
|
725
|
+
return {
|
|
726
|
+
route: (os.environ.get(var) or os.environ.get(ENV_VAR[provider]) or DEFAULT_UPSTREAM[provider]).rstrip("/")
|
|
727
|
+
for route, (provider, var) in ROUTES.items()
|
|
728
|
+
}
|
|
550
729
|
TIMEOUT = float(os.environ.get("PREMAN_AGENT_TIMEOUT", "600"))
|
|
551
730
|
_HOP = {
|
|
552
731
|
"connection", "keep-alive", "proxy-connection", "proxy-authorization", "transfer-encoding",
|
|
@@ -579,10 +758,7 @@ def version() -> str:
|
|
|
579
758
|
|
|
580
759
|
class Proxy:
|
|
581
760
|
def __init__(self, upstream=None):
|
|
582
|
-
self.upstream = upstream
|
|
583
|
-
provider: (os.environ.get(var) or DEFAULT_UPSTREAM[provider]).rstrip("/")
|
|
584
|
-
for provider, var in ENV_VAR.items()
|
|
585
|
-
}
|
|
761
|
+
self.upstream = _upstreams(upstream)
|
|
586
762
|
self._channels = {}
|
|
587
763
|
self._lock = threading.Lock()
|
|
588
764
|
self._server = _Server(("127.0.0.1", 0), _handler(self))
|
|
@@ -597,7 +773,7 @@ class Proxy:
|
|
|
597
773
|
return token
|
|
598
774
|
|
|
599
775
|
def env(self, token: str) -> dict:
|
|
600
|
-
env = {var: "http://127.0.0.1:%d/%s/%s" % (self.port, token,
|
|
776
|
+
env = {var: "http://127.0.0.1:%d/%s/%s" % (self.port, token, route) for route, (_, var) in ROUTES.items()}
|
|
601
777
|
# An agent behind HTTP(S)_PROXY must still reach this local proxy directly.
|
|
602
778
|
hosts = [h.strip() for h in (os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or "").split(",") if h.strip()]
|
|
603
779
|
hosts += [h for h in ("127.0.0.1", "localhost") if h not in hosts]
|
|
@@ -616,6 +792,7 @@ class Proxy:
|
|
|
616
792
|
if channel is None:
|
|
617
793
|
capture = _not_visible(_CHANNEL_CLOSED)
|
|
618
794
|
capture["trace"] = {"links": "order_only", "reason": _CHANNEL_CLOSED, "known": [], "unknown": {}, "calls": []}
|
|
795
|
+
capture["model_calls"] = []
|
|
619
796
|
capture["proxy_version"] = version()
|
|
620
797
|
return [], capture
|
|
621
798
|
turn, channel["turn"] = channel["turn"], None
|
|
@@ -625,6 +802,7 @@ class Proxy:
|
|
|
625
802
|
capture = dict(
|
|
626
803
|
_not_visible("a model request arrived between turns, so it was not filed under any of them"),
|
|
627
804
|
trace=capture.get("trace"),
|
|
805
|
+
model_calls=capture.get("model_calls", []),
|
|
628
806
|
)
|
|
629
807
|
capture["proxy_version"] = version()
|
|
630
808
|
return events, capture
|
|
@@ -684,21 +862,29 @@ def _handler(proxy):
|
|
|
684
862
|
self.close_connection = True
|
|
685
863
|
|
|
686
864
|
def _forward(self):
|
|
687
|
-
parts = self.path.split("/", 3) # ["", token,
|
|
688
|
-
if len(parts) < 3 or parts[2] not in
|
|
865
|
+
parts = self.path.split("/", 3) # ["", token, route, rest]
|
|
866
|
+
if len(parts) < 3 or parts[2] not in ROUTES:
|
|
689
867
|
return self._refuse(404, "not a PreMan proxy address")
|
|
690
|
-
token,
|
|
868
|
+
token, route = parts[1], parts[2]
|
|
869
|
+
provider = ROUTES[route][0]
|
|
691
870
|
rest = "/" + parts[3] if len(parts) > 3 else ""
|
|
692
871
|
kind = kind_of(provider, rest)
|
|
693
872
|
turn, known = proxy._turn_for(token, kind)
|
|
694
873
|
if not known:
|
|
695
874
|
return self._refuse(404, "not a PreMan proxy address")
|
|
875
|
+
plain = rest.split("?", 1)[0]
|
|
876
|
+
if turn is not None and not kind and self.command == "POST" and not plain.rstrip("/").endswith("count_tokens"):
|
|
877
|
+
turn.saw_other(plain)
|
|
696
878
|
try:
|
|
697
879
|
body = self._body()
|
|
698
880
|
except ValueError:
|
|
699
881
|
return self._refuse(400, "malformed request body")
|
|
700
882
|
conv = None
|
|
883
|
+
started = time.monotonic()
|
|
884
|
+
facts = ("", False)
|
|
701
885
|
if turn is not None and kind:
|
|
886
|
+
facts = _request_facts(body)
|
|
887
|
+
turn.request_started()
|
|
702
888
|
try:
|
|
703
889
|
conv = turn.saw_request(kind, body)
|
|
704
890
|
except Exception:
|
|
@@ -706,7 +892,7 @@ def _handler(proxy):
|
|
|
706
892
|
|
|
707
893
|
headers = {k: v for k, v in self.headers.items() if k.lower() not in _HOP}
|
|
708
894
|
request = urllib.request.Request(
|
|
709
|
-
proxy.upstream[
|
|
895
|
+
proxy.upstream[route] + rest,
|
|
710
896
|
data=body if body or self.command in ("POST", "PUT", "PATCH") else None,
|
|
711
897
|
headers=headers,
|
|
712
898
|
method=self.command,
|
|
@@ -717,6 +903,8 @@ def _handler(proxy):
|
|
|
717
903
|
reply = exc # a 4xx/5xx reply still goes back to the agent unchanged
|
|
718
904
|
except OSError:
|
|
719
905
|
self.close_connection = True # unreachable: the agent sees a dropped connection
|
|
906
|
+
if turn is not None and kind:
|
|
907
|
+
turn.request_ended()
|
|
720
908
|
return
|
|
721
909
|
|
|
722
910
|
bodyless = reply.status < 200 or reply.status in (204, 304)
|
|
@@ -766,6 +954,10 @@ def _handler(proxy):
|
|
|
766
954
|
bytes(copy),
|
|
767
955
|
len(copy) > MAX_BODY,
|
|
768
956
|
conv,
|
|
957
|
+
ms=(time.monotonic() - started) * 1000,
|
|
958
|
+
cut=cut,
|
|
959
|
+
request_model=facts[0],
|
|
960
|
+
asked_usage=facts[1],
|
|
769
961
|
)
|
|
770
962
|
except Exception:
|
|
771
963
|
turn._fail("a model reply could not be read")
|
|
@@ -796,6 +988,15 @@ def shared() -> Proxy:
|
|
|
796
988
|
|
|
797
989
|
|
|
798
990
|
def choose(proxied, reported=None):
|
|
991
|
+
"""The turn's account (see _choose), always carrying the proxy's token records:
|
|
992
|
+
an agent's own report says nothing about tokens."""
|
|
993
|
+
chosen = _choose(proxied, reported)
|
|
994
|
+
if "model_calls" in chosen[1] or "model_calls" not in proxied[1]:
|
|
995
|
+
return chosen
|
|
996
|
+
return chosen[0], dict(chosen[1], model_calls=proxied[1]["model_calls"])
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _choose(proxied, reported=None):
|
|
799
1000
|
"""The proxy's account when it saw the turn; otherwise (it saw no traffic, its
|
|
800
1001
|
channel closed, or no tools were offered) the agent's own report if that one
|
|
801
1002
|
was recorded. One source per
|
|
@@ -814,7 +1015,7 @@ def choose(proxied, reported=None):
|
|
|
814
1015
|
return proxied[0], capture
|
|
815
1016
|
if proxied[1].get("state") == "recorded" or reported is None:
|
|
816
1017
|
return proxied
|
|
817
|
-
if reported[1].get("state") == "recorded" and proxied[1].get("reason")
|
|
1018
|
+
if reported[1].get("state") == "recorded" and _unseen(proxied[1].get("reason")):
|
|
818
1019
|
capture = dict(reported[1])
|
|
819
1020
|
capture["proxy_version"] = proxied[1].get("proxy_version", version())
|
|
820
1021
|
return reported[0], capture
|