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 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
- // Offered only when the harness is the only thing that has to import
1272
- // anything. A `callable` target imports the customer's agent in-process, so
1273
- // it must resolve against an environment that has *their* dependencies —
1274
- // PreMan's bundle has assert-ai and nothing of theirs.
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
@@ -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
@@ -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 name and shutil.which(name):
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
- if (typeof agent !== "function") {
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 agent(message, history ?? []);
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 saw_reply(self, kind, status, content_type, content_encoding, body, truncated, conv=None):
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
- return self._saw_reply(kind, status, content_type, content_encoding, body, truncated, conv)
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
- return self._fail("a model reply was larger than 2 MB, so it was forwarded without being read")
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 "text/event-stream" in (content_type or ""):
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 or {
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, provider) for provider, var in ENV_VAR.items()}
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, provider, rest]
688
- if len(parts) < 3 or parts[2] not in ENV_VAR:
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, provider = parts[1], parts[2]
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[provider] + rest,
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") in _UNSEEN:
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {