@wendellresearch-ai/benchwarmers 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -1
- package/GATEWAY.md +306 -6
- package/README.md +53 -3
- package/USAGE.md +31 -0
- package/VERSION +1 -1
- package/cli.py +10 -2
- package/gateway.py +619 -93
- package/metrics/bench_collections.py +7 -3
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +764 -131
- package/metrics/cost.py +38 -19
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +1584 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +7 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_auth.py +2 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +558 -0
- package/scripts/test_merged.py +6 -5
- package/scripts/test_native.py +188 -0
- package/scripts/test_quota.py +334 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +170 -44
- package/ui/bench.js +6 -3
package/metrics/cost.py
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
"""What each model actually costs us, from the `llm` CLI's
|
|
1
|
+
"""What each model actually costs us, from the bench's usage ledger and the `llm` CLI's log.
|
|
2
2
|
|
|
3
3
|
python metrics/cost.py
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
TWO SOURCES, NO CALL IN BOTH. The built-in client writes a line for every
|
|
6
|
+
call to the usage ledger (metrics/ledger.py, RELEASE-PLAN Phase 2b); calls
|
|
7
|
+
made through the `llm` backend, and every call before the switch, are in
|
|
8
|
+
llm's own logs.db, which it has kept all along (382 turns with token counts
|
|
9
|
+
were on disk before this file existed). The built-in client never writes
|
|
10
|
+
logs.db and `llm` never writes the ledger, so the two are added up.
|
|
8
11
|
|
|
9
12
|
WHY REASONING TOKENS MATTER SEPARATELY
|
|
10
13
|
--------------------------------------
|
|
@@ -77,33 +80,49 @@ def benchmark_calls() -> dict[str, int]:
|
|
|
77
80
|
return {}
|
|
78
81
|
|
|
79
82
|
|
|
80
|
-
def
|
|
81
|
-
"""
|
|
83
|
+
def _llm_turns():
|
|
84
|
+
"""(model, input, output, reasoning or None, duration_ms) per turn in llm's log."""
|
|
82
85
|
if not LOGS.exists():
|
|
83
|
-
return
|
|
84
|
-
rename = alias_history()
|
|
86
|
+
return
|
|
85
87
|
db = sqlite3.connect(f"file:{LOGS}?mode=ro", uri=True)
|
|
86
88
|
db.row_factory = sqlite3.Row
|
|
87
|
-
|
|
88
|
-
agg: dict[str, dict] = collections.defaultdict(
|
|
89
|
-
lambda: {"calls": 0, "input": 0, "output": 0, "reasoning": 0,
|
|
90
|
-
"duration_ms": 0, "with_reasoning_data": 0})
|
|
91
89
|
for row in db.execute(
|
|
92
90
|
"SELECT model, input_tokens, output_tokens, token_details,"
|
|
93
91
|
" duration_ms FROM turns"):
|
|
94
|
-
# Turns predating the versioned rename carry the old alias.
|
|
95
|
-
model = rename.get(row["model"], row["model"])
|
|
96
|
-
a = agg[model]
|
|
97
|
-
a["calls"] += 1
|
|
98
|
-
a["input"] += row["input_tokens"] or 0
|
|
99
|
-
a["output"] += row["output_tokens"] or 0
|
|
100
|
-
a["duration_ms"] += row["duration_ms"] or 0
|
|
101
92
|
try:
|
|
102
93
|
details = json.loads(row["token_details"] or "{}") or {}
|
|
103
94
|
except ValueError:
|
|
104
95
|
details = {}
|
|
105
96
|
reasoning = ((details.get("completion_tokens_details") or {})
|
|
106
97
|
.get("reasoning_tokens"))
|
|
98
|
+
yield (row["model"], row["input_tokens"], row["output_tokens"], reasoning,
|
|
99
|
+
row["duration_ms"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ledger_calls():
|
|
103
|
+
"""The same, per answered call in the usage ledger. Refused and failed
|
|
104
|
+
calls spent nothing, as llm's log (which keeps only answers) counts."""
|
|
105
|
+
import ledger
|
|
106
|
+
for row in ledger.rows():
|
|
107
|
+
if row.get("status") in ("ok", "empty"):
|
|
108
|
+
yield (row.get("alias") or "?", row.get("tokens_in"), row.get("tokens_out"),
|
|
109
|
+
row.get("reasoning"), round((row.get("seconds") or 0) * 1000))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def collect() -> dict:
|
|
113
|
+
"""Per-model totals, with old aliases folded into their current name."""
|
|
114
|
+
rename = alias_history()
|
|
115
|
+
agg: dict[str, dict] = collections.defaultdict(
|
|
116
|
+
lambda: {"calls": 0, "input": 0, "output": 0, "reasoning": 0,
|
|
117
|
+
"duration_ms": 0, "with_reasoning_data": 0})
|
|
118
|
+
for name, tokens_in, tokens_out, reasoning, ms in (*_llm_turns(), *_ledger_calls()):
|
|
119
|
+
# Turns predating the versioned rename carry the old alias.
|
|
120
|
+
model = rename.get(name, name)
|
|
121
|
+
a = agg[model]
|
|
122
|
+
a["calls"] += 1
|
|
123
|
+
a["input"] += tokens_in or 0
|
|
124
|
+
a["output"] += tokens_out or 0
|
|
125
|
+
a["duration_ms"] += ms or 0
|
|
107
126
|
if reasoning is not None:
|
|
108
127
|
a["reasoning"] += reasoning
|
|
109
128
|
a["with_reasoning_data"] += 1
|
package/metrics/endpoints.py
CHANGED
|
@@ -88,6 +88,18 @@ HUBS = {"openwebui": "Open WebUI", "litellm": "LiteLLM", "cliproxy": "CLI Proxy
|
|
|
88
88
|
# endpoint(), plan(connection, model) and add(connection, model), and the two
|
|
89
89
|
# whose connections only the hub knows also upstreams(), for Discover.
|
|
90
90
|
ADAPTERS = {"openwebui": "owui", "litellm": "litellm_hub", "cliproxy": "cliproxy"}
|
|
91
|
+
# `api`: what an endpoint speaks, chat completions unless it says otherwise.
|
|
92
|
+
# The built-in client builds the others' native requests (metrics/formats.py);
|
|
93
|
+
# `responses` is set by Connect for ChatGPT's plan, the native two on the Add
|
|
94
|
+
# form, for an API key whose provider has no chat completions route.
|
|
95
|
+
APIS = {"chat": "OpenAI chat completions", "responses": "OpenAI Responses",
|
|
96
|
+
"anthropic": "Anthropic Messages (native)", "gemini": "Gemini generateContent (native)"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def auth_headers(api: str, secret: str) -> dict:
|
|
100
|
+
"""How a key is sent: Bearer, except to Anthropic's and Gemini's native APIs."""
|
|
101
|
+
return {"anthropic": {"x-api-key": secret}, "gemini": {"x-goog-api-key": secret}}.get(
|
|
102
|
+
api, {"Authorization": f"Bearer {secret}"})
|
|
91
103
|
|
|
92
104
|
|
|
93
105
|
def adapter(which: str):
|
|
@@ -271,13 +283,24 @@ def list_models(entry: dict, secret: str = "", timeout: int = 30) -> dict[str, d
|
|
|
271
283
|
before saving it). `models_url` when the list lives elsewhere (GitHub
|
|
272
284
|
Models: its catalog, a bare JSON list); entries marked `visibility` other
|
|
273
285
|
than "list" are left out (ChatGPT's plan lists ones it will not serve)."""
|
|
286
|
+
api = entry.get("api") or "chat"
|
|
274
287
|
headers = dict(entry.get("headers") or {})
|
|
275
288
|
if secret:
|
|
276
|
-
headers
|
|
289
|
+
headers.update(auth_headers(api, secret))
|
|
290
|
+
if api == "anthropic":
|
|
291
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
277
292
|
url = entry.get("models_url") or entry["base"].rstrip("/") + "/models"
|
|
293
|
+
if api == "gemini" and not entry.get("models_url"):
|
|
294
|
+
url += "?pageSize=1000" # 50 a page otherwise
|
|
278
295
|
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=timeout) as response:
|
|
279
296
|
data = json.load(response)
|
|
280
|
-
|
|
297
|
+
if api == "gemini" and isinstance(data, dict):
|
|
298
|
+
# Gemini's list: {"models": [{"name": "models/gemini-x", ...}]}; only
|
|
299
|
+
# what generateContent serves, by its bare name.
|
|
300
|
+
rows = [{**m, "id": str(m.get("name", "")).removeprefix("models/")} for m in data.get("models", [])
|
|
301
|
+
if isinstance(m, dict) and "generateContent" in (m.get("supportedGenerationMethods") or ())]
|
|
302
|
+
else:
|
|
303
|
+
rows = data.get("data", []) if isinstance(data, dict) else data
|
|
281
304
|
return {e["id"]: e for e in rows
|
|
282
305
|
if isinstance(e, dict) and e.get("id") and e.get("visibility", "list") == "list"}
|
|
283
306
|
|