margin-meter 0.6.1__tar.gz → 0.6.2__tar.gz
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.
- {margin_meter-0.6.1/margin_meter.egg-info → margin_meter-0.6.2}/PKG-INFO +7 -1
- margin_meter-0.6.1/PKG-INFO → margin_meter-0.6.2/README.md +6 -12
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/__init__.py +1 -1
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/__main__.py +17 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/braintrust.py +11 -17
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/client.py +72 -2
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/deepeval.py +6 -2
- margin_meter-0.6.2/margin_meter/evals.py +218 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/instrument.py +131 -45
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/langfuse.py +4 -5
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/langsmith.py +10 -17
- margin_meter-0.6.2/margin_meter/model_override.py +279 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/promptfoo.py +15 -5
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/providers.py +166 -18
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/pytest_plugin.py +23 -4
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/ragas.py +11 -13
- margin_meter-0.6.2/margin_meter/values.py +113 -0
- margin_meter-0.6.1/README.md → margin_meter-0.6.2/margin_meter.egg-info/PKG-INFO +18 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter.egg-info/SOURCES.txt +3 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/batching.py +0 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/doctor.py +0 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter/substrate.py +0 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter.egg-info/dependency_links.txt +0 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter.egg-info/entry_points.txt +0 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/margin_meter.egg-info/top_level.txt +0 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/pyproject.toml +0 -0
- {margin_meter-0.6.1 → margin_meter-0.6.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin-meter
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: Tiny stdlib-only client that auto-instruments OpenAI/Anthropic/Gemini and emits LLM call + outcome economics to a Margin ingest API.
|
|
5
5
|
Author: Margin
|
|
6
6
|
License: MIT
|
|
@@ -12,6 +12,12 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
|
|
13
13
|
# margin-meter (Python SDK)
|
|
14
14
|
|
|
15
|
+
> **You probably do not need this to start.** Margin's first step installs nothing: sign in at
|
|
16
|
+
> [trymargin.io/console](https://trymargin.io/console), connect the repository your agents live
|
|
17
|
+
> in (or drop the folder), and press Execute. Each agent runs on the evals beside it and every
|
|
18
|
+
> call is metered. This SDK is the **optional** route for metering your live production traffic,
|
|
19
|
+
> once you want that as well ([`/start#production`](https://trymargin.io/start#production)).
|
|
20
|
+
|
|
15
21
|
The tiny client a **Python** project imports to connect to Margin. It wraps your
|
|
16
22
|
LLM calls and records their outcomes, emitting each one **over HTTP** to a
|
|
17
23
|
Margin **ingest API** (`POST /api/ingest/calls` / `/api/ingest/outcomes`),
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: margin-meter
|
|
3
|
-
Version: 0.6.1
|
|
4
|
-
Summary: Tiny stdlib-only client that auto-instruments OpenAI/Anthropic/Gemini and emits LLM call + outcome economics to a Margin ingest API.
|
|
5
|
-
Author: Margin
|
|
6
|
-
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://trymargin.io
|
|
8
|
-
Project-URL: Source, https://pypi.org/project/margin-meter/
|
|
9
|
-
Keywords: llm,cost,observability,finops,margin
|
|
10
|
-
Requires-Python: >=3.9
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
|
|
13
1
|
# margin-meter (Python SDK)
|
|
14
2
|
|
|
3
|
+
> **You probably do not need this to start.** Margin's first step installs nothing: sign in at
|
|
4
|
+
> [trymargin.io/console](https://trymargin.io/console), connect the repository your agents live
|
|
5
|
+
> in (or drop the folder), and press Execute. Each agent runs on the evals beside it and every
|
|
6
|
+
> call is metered. This SDK is the **optional** route for metering your live production traffic,
|
|
7
|
+
> once you want that as well ([`/start#production`](https://trymargin.io/start#production)).
|
|
8
|
+
|
|
15
9
|
The tiny client a **Python** project imports to connect to Margin. It wraps your
|
|
16
10
|
LLM calls and records their outcomes, emitting each one **over HTTP** to a
|
|
17
11
|
Margin **ingest API** (`POST /api/ingest/calls` / `/api/ingest/outcomes`),
|
|
@@ -106,4 +106,4 @@ __all__ = [
|
|
|
106
106
|
# closing the invoice-reconcile coverage gap is "ship the fix, wait for the seed's
|
|
107
107
|
# dependency refresh, re-run the reconcile", and a runtime that misreports which
|
|
108
108
|
# SDK metered a call makes that unanswerable.
|
|
109
|
-
__version__ = "0.6.
|
|
109
|
+
__version__ = "0.6.2"
|
|
@@ -13,6 +13,10 @@ Subcommands:
|
|
|
13
13
|
* ``langfuse`` — report Langfuse scores as outcomes (rung 2).
|
|
14
14
|
* ``langfuse-calls`` — report Langfuse GENERATION observations as metered calls
|
|
15
15
|
(rung 2, the AI-in plug — Langfuse is the one source that carries both).
|
|
16
|
+
* ``evals`` — draft eval criteria from the connected repo, list them, or open a PR
|
|
17
|
+
with the accepted ones (MAR-1167).
|
|
18
|
+
* ``mcp`` — the same eval actions as an MCP server on stdio.
|
|
19
|
+
* ``values`` — push what each outcome is worth from your own records (MAR-1496).
|
|
16
20
|
|
|
17
21
|
Kept as a dispatcher so the surface is stable as more adapters/diagnostics arrive.
|
|
18
22
|
"""
|
|
@@ -26,10 +30,12 @@ from margin_meter import (
|
|
|
26
30
|
braintrust,
|
|
27
31
|
deepeval,
|
|
28
32
|
doctor,
|
|
33
|
+
evals,
|
|
29
34
|
langfuse,
|
|
30
35
|
langsmith,
|
|
31
36
|
promptfoo,
|
|
32
37
|
ragas,
|
|
38
|
+
values,
|
|
33
39
|
)
|
|
34
40
|
|
|
35
41
|
_USAGE = """margin-meter — Margin's metering SDK
|
|
@@ -55,6 +61,11 @@ commands:
|
|
|
55
61
|
langfuse-calls
|
|
56
62
|
report Langfuse GENERATION observations to Margin as metered calls
|
|
57
63
|
(cost from calculatedTotalCost, else priced from tokens)
|
|
64
|
+
evals draft | list | pr: draft eval criteria from the connected repo,
|
|
65
|
+
list them, or open a PR with the ones a person accepted
|
|
66
|
+
mcp serve the evals actions as MCP tools over stdio
|
|
67
|
+
values push what each outcome is worth from your own records
|
|
68
|
+
(event_id,value_usd CSV on stdin or a file; --source, --field)
|
|
58
69
|
|
|
59
70
|
run `margin-meter <command> --help` for a command's options.
|
|
60
71
|
"""
|
|
@@ -82,6 +93,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
82
93
|
return langfuse.main(rest)
|
|
83
94
|
if command == "langfuse-calls":
|
|
84
95
|
return langfuse.main_calls(rest)
|
|
96
|
+
if command == "evals":
|
|
97
|
+
return evals.main(rest)
|
|
98
|
+
if command == "mcp":
|
|
99
|
+
return evals.serve(rest)
|
|
100
|
+
if command == "values":
|
|
101
|
+
return values.main(rest)
|
|
85
102
|
print(f"margin-meter: unknown command {command!r}\n", file=sys.stderr)
|
|
86
103
|
print(_USAGE, file=sys.stderr)
|
|
87
104
|
return 2
|
|
@@ -36,8 +36,8 @@ The mapping, one Braintrust result → one outcome:
|
|
|
36
36
|
the grade is an LLM judge (rung 6 provenance); omitted otherwise. A bare
|
|
37
37
|
``model`` field is NOT used — in a Braintrust record it is usually the
|
|
38
38
|
generation model under test, not the judge.
|
|
39
|
-
* ``link`` ← the case ``id`` / ``span_id``, else
|
|
40
|
-
``input
|
|
39
|
+
* ``link`` ← the case ``id`` / ``span_id``, else ``braintrust:<index>`` —
|
|
40
|
+
never its ``input`` (MAR-1548).
|
|
41
41
|
* ``value_usd`` ← opt-in via ``metadata.margin_value`` — the only route to
|
|
42
42
|
value-per-outcome (rung 8). Absent is the honest cost-per-outcome default,
|
|
43
43
|
never defaulted to 0.
|
|
@@ -101,7 +101,7 @@ _JUDGE_MODEL_KEY = "margin_judge_model"
|
|
|
101
101
|
# Mirror ``src/margin/ingest.MAX_VALUE_USD`` (kept as a literal so the SDK has no
|
|
102
102
|
# dependency on the server package). A value above this is rejected by ingest and
|
|
103
103
|
# would take the whole outcome row down with it, so the adapter drops the value
|
|
104
|
-
# and keeps the row — the same treatment a
|
|
104
|
+
# and keeps the row — the same treatment a malformed value gets.
|
|
105
105
|
_MAX_VALUE_USD = 10_000_000.0
|
|
106
106
|
|
|
107
107
|
# Model ids that name no real model — a custom/deterministic scorer leaves the
|
|
@@ -296,28 +296,22 @@ def value_from_result(result: dict) -> Optional[float]:
|
|
|
296
296
|
raise ValueError(f"{_VALUE_KEY} must be a number, got {raw!r}")
|
|
297
297
|
if not math.isfinite(val):
|
|
298
298
|
raise ValueError(f"{_VALUE_KEY} must be finite, got {raw!r}")
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
raise ValueError(f"{_VALUE_KEY} implausible (> {_MAX_VALUE_USD}), got {val!r}")
|
|
299
|
+
# A loss is a real value (MAR-1198): ingest takes any finite amount within +/-MAX.
|
|
300
|
+
if abs(val) > _MAX_VALUE_USD:
|
|
301
|
+
raise ValueError(f"{_VALUE_KEY} implausible (|value| > {_MAX_VALUE_USD}), got {val!r}")
|
|
303
302
|
return val
|
|
304
303
|
|
|
305
304
|
|
|
306
305
|
def link_from_result(result: dict, index: int) -> str:
|
|
307
|
-
"""A traceable link back to the exact eval case: its id/span id, else
|
|
308
|
-
|
|
306
|
+
"""A traceable link back to the exact eval case: its id/span id, else the row
|
|
307
|
+
index.
|
|
308
|
+
|
|
309
|
+
⛔ Never the case's ``input`` (MAR-1548): /security says no prompt reaches us.
|
|
310
|
+
"""
|
|
309
311
|
for key in ("id", "span_id", "root_span_id"):
|
|
310
312
|
val = result.get(key)
|
|
311
313
|
if isinstance(val, str) and val.strip():
|
|
312
314
|
return val.strip()[:_MAX_LINK_LEN]
|
|
313
|
-
inp = result.get("input")
|
|
314
|
-
if isinstance(inp, str) and inp.strip():
|
|
315
|
-
return inp.strip()[:_MAX_LINK_LEN]
|
|
316
|
-
if isinstance(inp, (dict, list)):
|
|
317
|
-
try:
|
|
318
|
-
return json.dumps(inp, ensure_ascii=False, sort_keys=True)[:_MAX_LINK_LEN]
|
|
319
|
-
except (TypeError, ValueError):
|
|
320
|
-
pass
|
|
321
315
|
return f"braintrust:{index}"
|
|
322
316
|
|
|
323
317
|
|
|
@@ -46,6 +46,8 @@ import functools
|
|
|
46
46
|
import json as _json
|
|
47
47
|
import contextlib
|
|
48
48
|
import logging
|
|
49
|
+
import contextvars
|
|
50
|
+
import inspect
|
|
49
51
|
import os
|
|
50
52
|
import random
|
|
51
53
|
import time
|
|
@@ -623,6 +625,44 @@ def _retry_after_seconds(resp: Any) -> Optional[float]:
|
|
|
623
625
|
return None
|
|
624
626
|
|
|
625
627
|
|
|
628
|
+
_SDK_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
#: The calling file, set on the CALLING thread by a wrapper whose metering record may be written
|
|
632
|
+
#: on another one (MAR-1230). litellm writes its success callbacks from a thread pool but runs
|
|
633
|
+
#: them in a copy of the caller's context, so a value set here reaches that thread.
|
|
634
|
+
_SITE_HINT: "contextvars.ContextVar[str]" = contextvars.ContextVar("margin_call_site", default="")
|
|
635
|
+
#: Set as the hint by a callback that has no calling thread to read (MAR-1236): the record then
|
|
636
|
+
#: names no file rather than whatever unrelated frame sits at the bottom of that thread. A
|
|
637
|
+
#: litellm stream, sync or async, is logged after the call has returned, so it names no file.
|
|
638
|
+
_NO_SITE = "\0"
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _call_site() -> str:
|
|
642
|
+
"""The file in the working tree that made this call, relative to it (MAR-1227): the
|
|
643
|
+
innermost frame that is neither this package nor an installed library. It rides on a SINK
|
|
644
|
+
line only, next to ``case``, so a harness can split a case's spend by the agent file that
|
|
645
|
+
spent it; ingest never sees it. Empty when no such frame exists."""
|
|
646
|
+
import sys
|
|
647
|
+
hint = _SITE_HINT.get()
|
|
648
|
+
if hint:
|
|
649
|
+
return "" if hint == _NO_SITE else hint
|
|
650
|
+
try:
|
|
651
|
+
root = os.path.realpath(os.getcwd())
|
|
652
|
+
except OSError: # the working directory was removed: no site, but keep the call metered
|
|
653
|
+
return ""
|
|
654
|
+
frame = sys._getframe(1)
|
|
655
|
+
while frame is not None:
|
|
656
|
+
name = frame.f_code.co_filename
|
|
657
|
+
# "<frozen runpy>", "<string>": not files, though realpath would place them in cwd.
|
|
658
|
+
path = "" if name.startswith("<") else os.path.realpath(name)
|
|
659
|
+
if (path.startswith(root + os.sep) and not path.startswith(_SDK_DIR)
|
|
660
|
+
and "site-packages" not in path and "dist-packages" not in path):
|
|
661
|
+
return os.path.relpath(path, root).replace(os.sep, "/")
|
|
662
|
+
frame = frame.f_back
|
|
663
|
+
return ""
|
|
664
|
+
|
|
665
|
+
|
|
626
666
|
def _backoff_seconds(attempt: int, retry_after: Optional[float] = None) -> float:
|
|
627
667
|
"""FULL jitter — ``random(0, base * 2**attempt)``, capped, or the server's wait.
|
|
628
668
|
|
|
@@ -1146,8 +1186,23 @@ class MarginMeter:
|
|
|
1146
1186
|
def _write_sink(self, path: str, payload: dict) -> IngestResult:
|
|
1147
1187
|
"""Append one record to the JSONL sink for later forwarding. Fail-safe."""
|
|
1148
1188
|
try:
|
|
1189
|
+
record = {"path": path, "payload": payload}
|
|
1190
|
+
# The pytest case this record was made in (MAR-1207). It rides on the SINK line
|
|
1191
|
+
# only, never in the payload, so a harness can tell a case that called a model
|
|
1192
|
+
# from one that did not; ingest never sees it.
|
|
1193
|
+
case = os.environ.get("PYTEST_CURRENT_TEST") or ""
|
|
1194
|
+
for phase in (" (setup)", " (call)", " (teardown)"):
|
|
1195
|
+
if case.endswith(phase):
|
|
1196
|
+
case = case[: -len(phase)]
|
|
1197
|
+
if case:
|
|
1198
|
+
record["case"] = case
|
|
1199
|
+
# The calling file rides on every sink line (MAR-1230), cased or not, so a face-off
|
|
1200
|
+
# scoped to one agent can prove which of its calls were swapped.
|
|
1201
|
+
site = _call_site()
|
|
1202
|
+
if site:
|
|
1203
|
+
record["site"] = site
|
|
1149
1204
|
with open(self.ingest_file, "a", encoding="utf-8") as fh:
|
|
1150
|
-
fh.write(_json.dumps(
|
|
1205
|
+
fh.write(_json.dumps(record) + "\n")
|
|
1151
1206
|
except Exception as exc: # a sink write must never break the wrapped call
|
|
1152
1207
|
self._count(path, ok=False, reason="sink_write")
|
|
1153
1208
|
result = IngestResult(
|
|
@@ -1812,6 +1867,9 @@ class MarginMeter:
|
|
|
1812
1867
|
cache_creation_1h_tokens=usage.cache_creation_1h_tokens,
|
|
1813
1868
|
reasoning_tokens=usage.reasoning_tokens,
|
|
1814
1869
|
web_search_requests=usage.web_search_requests,
|
|
1870
|
+
# OpenRouter's billed cost for a fan-out slug (MAR-1309); None everywhere
|
|
1871
|
+
# else, which leaves the server to price tokens off its table as before.
|
|
1872
|
+
cost_usd=usage.billed_cost_usd,
|
|
1815
1873
|
latency_ms=latency_ms,
|
|
1816
1874
|
status=status,
|
|
1817
1875
|
is_retry=is_retry,
|
|
@@ -2018,6 +2076,9 @@ class MarginMeter:
|
|
|
2018
2076
|
# accumulator max-merges it, so forward it or a streamed search
|
|
2019
2077
|
# drops its flat $10/1,000 charge and under-bills (MAR-499).
|
|
2020
2078
|
web_search_requests=usage.web_search_requests,
|
|
2079
|
+
# A streamed fan-out's billed cost (MAR-1309), merged off the
|
|
2080
|
+
# final usage chunk; None for every other stream.
|
|
2081
|
+
cost_usd=usage.billed_cost_usd,
|
|
2021
2082
|
latency_ms=latency_ms,
|
|
2022
2083
|
status="ok",
|
|
2023
2084
|
is_retry=is_retry,
|
|
@@ -2093,7 +2154,16 @@ class MarginMeter:
|
|
|
2093
2154
|
# from the supply chain — even though the host, which awaits the coroutine,
|
|
2094
2155
|
# keeps working. Detect a coroutine function and return an async wrapper that
|
|
2095
2156
|
# AWAITS the real response before metering, timing the full await.
|
|
2096
|
-
|
|
2157
|
+
# ⛔ openai 2.x and anthropic DECORATE their async create (``required_args``), so the
|
|
2158
|
+
# attribute itself is a plain function returning a coroutine and
|
|
2159
|
+
# ``iscoroutinefunction`` says False. Measured 2026-09-25 (MAR-1236): every
|
|
2160
|
+
# AsyncOpenAI chat call and every AsyncAnthropic messages call went unmetered. The
|
|
2161
|
+
# decorators keep ``__wrapped__``, so the real ``async def`` is found by unwrapping.
|
|
2162
|
+
try:
|
|
2163
|
+
real = inspect.unwrap(fn)
|
|
2164
|
+
except ValueError: # a __wrapped__ cycle (mock.call): classify the callable as given
|
|
2165
|
+
real = fn
|
|
2166
|
+
if asyncio.iscoroutinefunction(fn) or inspect.iscoroutinefunction(real):
|
|
2097
2167
|
|
|
2098
2168
|
@functools.wraps(fn)
|
|
2099
2169
|
async def _awrapped(*args: Any, **kwargs: Any) -> Any:
|
|
@@ -56,6 +56,9 @@ _MAX_JUDGE_ID_LEN = 128
|
|
|
56
56
|
|
|
57
57
|
_VALUE_KEY = "margin_value"
|
|
58
58
|
|
|
59
|
+
# Mirror ``src/margin/ingest.MAX_VALUE_USD``: ingest refuses a value past this either way.
|
|
60
|
+
_MAX_VALUE_USD = 10_000_000.0
|
|
61
|
+
|
|
59
62
|
# ``evaluationModel`` values that name no real model — a deterministic/custom
|
|
60
63
|
# metric leaves it empty or a placeholder. Normalized lower-case for the check.
|
|
61
64
|
_NON_MODELS = frozenset({"", "n/a", "na", "none", "null"})
|
|
@@ -158,8 +161,9 @@ def value_from_case(case: dict) -> Optional[float]:
|
|
|
158
161
|
raise ValueError(f"{_VALUE_KEY} must be a number, got {raw!r}")
|
|
159
162
|
if not math.isfinite(val):
|
|
160
163
|
raise ValueError(f"{_VALUE_KEY} must be finite, got {raw!r}")
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
# A loss is a real value (MAR-1198): ingest takes any finite amount within +/-MAX.
|
|
165
|
+
if abs(val) > _MAX_VALUE_USD:
|
|
166
|
+
raise ValueError(f"{_VALUE_KEY} implausible (|value| > {_MAX_VALUE_USD}), got {val!r}")
|
|
163
167
|
return val
|
|
164
168
|
|
|
165
169
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Evals from the command line and from MCP (MAR-1167).
|
|
2
|
+
|
|
3
|
+
The console is one door to the eval builder. This module opens two more for developers
|
|
4
|
+
who would rather stay in a terminal or an agent:
|
|
5
|
+
|
|
6
|
+
* ``margin-meter evals draft`` — draft criteria from the project's connected repository
|
|
7
|
+
* ``margin-meter evals list`` — the project's criteria, drafts and accepted
|
|
8
|
+
* ``margin-meter evals pr`` — open a PR carrying the ACCEPTED criteria into the repo
|
|
9
|
+
* ``margin-meter mcp`` — the same three as MCP tools over stdio
|
|
10
|
+
|
|
11
|
+
All of them call the same project-scoped HTTP routes the console calls, authenticated with
|
|
12
|
+
the project's ingest key (``MARGIN_INGEST_KEY``) against ``MARGIN_INGEST_URL``. Nothing here
|
|
13
|
+
can accept a criterion: acceptance needs a named person in a console session, so a PR opened
|
|
14
|
+
from a script still carries only rules a person on the customer's side agreed to.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from typing import Any, Callable, Optional, Sequence
|
|
26
|
+
|
|
27
|
+
DRAFT_PATH = "/api/console/evals/from-repo"
|
|
28
|
+
LIST_PATH = "/api/console/evals"
|
|
29
|
+
PR_PATH = "/api/console/evals/pull-request"
|
|
30
|
+
TIMEOUT_S = 180.0 # a draft reads and scans the whole repo; it is not an ingest call
|
|
31
|
+
|
|
32
|
+
# (method, path, key) -> (status, body). Tests inject a TestClient-backed one.
|
|
33
|
+
Transport = Callable[[str, str, str], "tuple[int, Any]"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def http_transport(base_url: str) -> Transport:
|
|
37
|
+
base = base_url.rstrip("/")
|
|
38
|
+
|
|
39
|
+
def send(method: str, path: str, key: str) -> "tuple[int, Any]":
|
|
40
|
+
req = urllib.request.Request(
|
|
41
|
+
base + path,
|
|
42
|
+
data=b"{}" if method == "POST" else None,
|
|
43
|
+
method=method,
|
|
44
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
|
|
48
|
+
raw, status = resp.read(), resp.status
|
|
49
|
+
except urllib.error.HTTPError as exc:
|
|
50
|
+
try:
|
|
51
|
+
raw = exc.read()
|
|
52
|
+
except Exception: # an HTTPError without a body must not mask the status
|
|
53
|
+
raw = b""
|
|
54
|
+
status = exc.code
|
|
55
|
+
except (urllib.error.URLError, OSError) as exc: # unreachable: report, never crash
|
|
56
|
+
raise EvalsError(f"could not reach {base}: {exc}") from exc
|
|
57
|
+
try:
|
|
58
|
+
return status, json.loads(raw or b"null")
|
|
59
|
+
except ValueError:
|
|
60
|
+
return status, raw.decode("utf-8", "replace")
|
|
61
|
+
|
|
62
|
+
return send
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EvalsError(RuntimeError):
|
|
66
|
+
"""The API refused; the message is the API's own reason."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _call(send: Transport, method: str, path: str, key: str) -> dict:
|
|
70
|
+
status, body = send(method, path, key)
|
|
71
|
+
if status >= 400:
|
|
72
|
+
detail = body.get("detail") if isinstance(body, dict) else body
|
|
73
|
+
raise EvalsError(f"HTTP {status}: {detail}")
|
|
74
|
+
return body if isinstance(body, dict) else {"result": body}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def draft(send: Transport, key: str) -> dict:
|
|
78
|
+
return _call(send, "POST", DRAFT_PATH, key)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_criteria(send: Transport, key: str) -> dict:
|
|
82
|
+
return _call(send, "GET", LIST_PATH, key)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def open_pr(send: Transport, key: str) -> dict:
|
|
86
|
+
return _call(send, "POST", PR_PATH, key)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_ACTIONS = {"draft": draft, "list": list_criteria, "pr": open_pr}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _config(url: Optional[str], key: Optional[str]) -> "tuple[str, str]":
|
|
93
|
+
url = url or os.environ.get("MARGIN_INGEST_URL") or ""
|
|
94
|
+
key = key or os.environ.get("MARGIN_INGEST_KEY") or ""
|
|
95
|
+
if not url or not key:
|
|
96
|
+
raise EvalsError("set MARGIN_INGEST_URL and MARGIN_INGEST_KEY (or pass --url/--key)")
|
|
97
|
+
return url, key
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _print_human(action: str, out: dict) -> None:
|
|
101
|
+
if action == "pr":
|
|
102
|
+
verb = "updated" if out.get("reused") else "opened"
|
|
103
|
+
print(f"{verb} PR #{out.get('number')} on {out.get('repo')}: {out.get('url')}")
|
|
104
|
+
return
|
|
105
|
+
for c in out.get("criteria", []):
|
|
106
|
+
where = ", ".join(c.get("evidence") or []) or "-"
|
|
107
|
+
print(f"[{c.get('label', c.get('status'))}] {c.get('text')} ({where})")
|
|
108
|
+
for h in out.get("harnesses", []) or []:
|
|
109
|
+
print(f"existing harness: {h}")
|
|
110
|
+
if out.get("why"):
|
|
111
|
+
print(out["why"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main(argv: Optional[Sequence[str]] = None, send: Optional[Transport] = None) -> int:
|
|
115
|
+
p = argparse.ArgumentParser(prog="margin-meter evals",
|
|
116
|
+
description="Draft, list and ship eval criteria.")
|
|
117
|
+
p.add_argument("action", choices=sorted(_ACTIONS))
|
|
118
|
+
p.add_argument("--url")
|
|
119
|
+
p.add_argument("--key")
|
|
120
|
+
p.add_argument("--json", action="store_true", help="print the raw API response")
|
|
121
|
+
a = p.parse_args(argv)
|
|
122
|
+
try:
|
|
123
|
+
url, key = _config(a.url, a.key)
|
|
124
|
+
out = _ACTIONS[a.action](send or http_transport(url), key)
|
|
125
|
+
except EvalsError as exc:
|
|
126
|
+
print(f"margin-meter evals: {exc}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
if a.json:
|
|
129
|
+
print(json.dumps(out, indent=2))
|
|
130
|
+
else:
|
|
131
|
+
_print_human(a.action, out)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------- #
|
|
136
|
+
# MCP over stdio: newline-delimited JSON-RPC 2.0, the three actions as tools.
|
|
137
|
+
# --------------------------------------------------------------------------- #
|
|
138
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
139
|
+
_TOOLS = [
|
|
140
|
+
{"name": "margin_evals_draft_from_repo",
|
|
141
|
+
"description": ("Read the project's connected GitHub repository and draft eval criteria "
|
|
142
|
+
"from the instructions its agent is given. Every criterion cites the "
|
|
143
|
+
"path:line it came from and stays a draft until a person accepts it "
|
|
144
|
+
"in the Margin console."),
|
|
145
|
+
"annotations": {"readOnlyHint": False, "destructiveHint": False}},
|
|
146
|
+
{"name": "margin_evals_list",
|
|
147
|
+
"description": "List the project's eval criteria with their status (draft or accepted).",
|
|
148
|
+
"annotations": {"readOnlyHint": True}},
|
|
149
|
+
{"name": "margin_evals_open_pr",
|
|
150
|
+
"description": ("Open (or update) a pull request that adds the project's ACCEPTED "
|
|
151
|
+
"criteria to margin/evals.json in its repository. Refused when nothing "
|
|
152
|
+
"is accepted yet. The customer reviews and merges it."),
|
|
153
|
+
"annotations": {"readOnlyHint": False, "destructiveHint": False}},
|
|
154
|
+
]
|
|
155
|
+
_TOOL_ACTION = {"margin_evals_draft_from_repo": draft, "margin_evals_list": list_criteria,
|
|
156
|
+
"margin_evals_open_pr": open_pr}
|
|
157
|
+
for _t in _TOOLS:
|
|
158
|
+
_t["inputSchema"] = {"type": "object", "properties": {}, "additionalProperties": False}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def handle(msg: dict, send: Transport, key: str) -> Optional[dict]:
|
|
162
|
+
"""One JSON-RPC message → its response (None for a notification)."""
|
|
163
|
+
if not isinstance(msg, dict):
|
|
164
|
+
return {"jsonrpc": "2.0", "id": None,
|
|
165
|
+
"error": {"code": -32600, "message": "invalid request"}}
|
|
166
|
+
if "id" not in msg:
|
|
167
|
+
return None
|
|
168
|
+
mid, method = msg["id"], msg.get("method")
|
|
169
|
+
if method == "initialize":
|
|
170
|
+
result: Any = {"protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}},
|
|
171
|
+
"serverInfo": {"name": "margin-evals", "version": "1"}}
|
|
172
|
+
elif method == "tools/list":
|
|
173
|
+
result = {"tools": _TOOLS}
|
|
174
|
+
elif method == "tools/call":
|
|
175
|
+
name = (msg.get("params") or {}).get("name")
|
|
176
|
+
action = _TOOL_ACTION.get(name)
|
|
177
|
+
if action is None:
|
|
178
|
+
return {"jsonrpc": "2.0", "id": mid,
|
|
179
|
+
"error": {"code": -32602, "message": f"unknown tool {name!r}"}}
|
|
180
|
+
try:
|
|
181
|
+
text, err = json.dumps(action(send, key), indent=2), False
|
|
182
|
+
except EvalsError as exc:
|
|
183
|
+
text, err = str(exc), True
|
|
184
|
+
result = {"content": [{"type": "text", "text": text}], "isError": err}
|
|
185
|
+
elif method == "ping":
|
|
186
|
+
result = {}
|
|
187
|
+
else:
|
|
188
|
+
return {"jsonrpc": "2.0", "id": mid,
|
|
189
|
+
"error": {"code": -32601, "message": f"method not found: {method}"}}
|
|
190
|
+
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def serve(argv: Optional[Sequence[str]] = None) -> int:
|
|
194
|
+
p = argparse.ArgumentParser(prog="margin-meter mcp",
|
|
195
|
+
description="Margin's eval tools as an MCP server on stdio.")
|
|
196
|
+
p.add_argument("--url")
|
|
197
|
+
p.add_argument("--key")
|
|
198
|
+
a = p.parse_args(argv)
|
|
199
|
+
try:
|
|
200
|
+
url, key = _config(a.url, a.key)
|
|
201
|
+
except EvalsError as exc:
|
|
202
|
+
print(f"margin-meter mcp: {exc}", file=sys.stderr)
|
|
203
|
+
return 1
|
|
204
|
+
send = http_transport(url)
|
|
205
|
+
for line in sys.stdin:
|
|
206
|
+
if not line.strip():
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
msg = json.loads(line)
|
|
210
|
+
except ValueError:
|
|
211
|
+
out: Optional[dict] = {"jsonrpc": "2.0", "id": None,
|
|
212
|
+
"error": {"code": -32700, "message": "parse error"}}
|
|
213
|
+
else:
|
|
214
|
+
out = handle(msg, send, key)
|
|
215
|
+
if out is not None:
|
|
216
|
+
sys.stdout.write(json.dumps(out) + "\n")
|
|
217
|
+
sys.stdout.flush()
|
|
218
|
+
return 0
|