@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.1
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 +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -373
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/gateway.py
CHANGED
|
@@ -1,712 +1,755 @@
|
|
|
1
|
-
"""The bench method as an OpenAI-compatible endpoint, at http://127.0.0.1:8123/v1.
|
|
2
|
-
|
|
3
|
-
python gateway.py
|
|
4
|
-
|
|
5
|
-
Ask for a COLLECTION by name — `bench/core`, `bench/reviewers` — and the prompt
|
|
6
|
-
goes in parallel to every member of it plus explorer seats `route.py` fills by
|
|
7
|
-
staleness, so the roster keeps getting evidence even when the same collection
|
|
8
|
-
is called every day:
|
|
9
|
-
|
|
10
|
-
explorers = min(max_explorers, max_calls - members) # defaults 2 and 5
|
|
11
|
-
|
|
12
|
-
Collections and those limits live in `config/collections.json` and
|
|
13
|
-
`config/gateway.json` (see `metrics/bench_collections.py`), edited from the
|
|
14
|
-
dashboard. There is no default: a request that names no collection, or one
|
|
15
|
-
that does not exist, is refused with every collection's description and tags,
|
|
16
|
-
so the caller can choose — `GET /v1/models` carries the same.
|
|
17
|
-
|
|
18
|
-
The reply is every answer, each labelled by seat and model with a call id,
|
|
19
|
-
including seats that failed or came back empty: who answered is evidence about
|
|
20
|
-
the roster, and a summary naming only the good answer hides it.
|
|
21
|
-
|
|
22
|
-
Unless the reply is `merged` (a collection's `reply`, or per request a
|
|
23
|
-
`:merged` suffix on the model or `"bench": {"reply": "merged"}`; `:all` the
|
|
24
|
-
other way): then, after the panel, one more model writes a single answer
|
|
25
|
-
saying where the seats agreed, where they disagreed and who said what, for a
|
|
26
|
-
client that can show only one. It has its own call id, so a verdict can judge
|
|
27
|
-
the merge, and the `bench` field still carries every seat. If no merger has
|
|
28
|
-
the context for the merge, or it fails, the reply is every answer with a
|
|
29
|
-
one-line note: the panel the caller waited for is never lost.
|
|
30
|
-
|
|
31
|
-
A collection's `shape` (review, design, …) decides which models may fill the
|
|
32
|
-
explorer seats. Left empty, a decision model labels the prompt when one that
|
|
33
|
-
takes choice questions is onboarded (not ad hoc); otherwise, or on any doubt,
|
|
34
|
-
the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
|
|
35
|
-
model in an explorer chair, as `route.py --with` does.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
import
|
|
90
|
-
|
|
91
|
-
import
|
|
92
|
-
import
|
|
93
|
-
import
|
|
94
|
-
import
|
|
95
|
-
import
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
from
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
import
|
|
115
|
-
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if
|
|
238
|
-
return "the
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
#
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
#
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
#
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
""
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
"""
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
for
|
|
381
|
-
|
|
382
|
-
if
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
raise
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
self.
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
self.
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
guard.
|
|
663
|
-
except guard.Refused as r:
|
|
664
|
-
return self._error(r.status, str(r), r.headers)
|
|
665
|
-
try:
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1
|
+
"""The bench method as an OpenAI-compatible endpoint, at http://127.0.0.1:8123/v1.
|
|
2
|
+
|
|
3
|
+
python gateway.py
|
|
4
|
+
|
|
5
|
+
Ask for a COLLECTION by name — `bench/core`, `bench/reviewers` — and the prompt
|
|
6
|
+
goes in parallel to every member of it plus explorer seats `route.py` fills by
|
|
7
|
+
staleness, so the roster keeps getting evidence even when the same collection
|
|
8
|
+
is called every day:
|
|
9
|
+
|
|
10
|
+
explorers = min(max_explorers, max_calls - members) # defaults 2 and 5
|
|
11
|
+
|
|
12
|
+
Collections and those limits live in `config/collections.json` and
|
|
13
|
+
`config/gateway.json` (see `metrics/bench_collections.py`), edited from the
|
|
14
|
+
dashboard. There is no default: a request that names no collection, or one
|
|
15
|
+
that does not exist, is refused with every collection's description and tags,
|
|
16
|
+
so the caller can choose — `GET /v1/models` carries the same.
|
|
17
|
+
|
|
18
|
+
The reply is every answer, each labelled by seat and model with a call id,
|
|
19
|
+
including seats that failed or came back empty: who answered is evidence about
|
|
20
|
+
the roster, and a summary naming only the good answer hides it.
|
|
21
|
+
|
|
22
|
+
Unless the reply is `merged` (a collection's `reply`, or per request a
|
|
23
|
+
`:merged` suffix on the model or `"bench": {"reply": "merged"}`; `:all` the
|
|
24
|
+
other way): then, after the panel, one more model writes a single answer
|
|
25
|
+
saying where the seats agreed, where they disagreed and who said what, for a
|
|
26
|
+
client that can show only one. It has its own call id, so a verdict can judge
|
|
27
|
+
the merge, and the `bench` field still carries every seat. If no merger has
|
|
28
|
+
the context for the merge, or it fails, the reply is every answer with a
|
|
29
|
+
one-line note: the panel the caller waited for is never lost.
|
|
30
|
+
|
|
31
|
+
A collection's `shape` (review, design, …) decides which models may fill the
|
|
32
|
+
explorer seats. Left empty, a decision model labels the prompt when one that
|
|
33
|
+
takes choice questions is onboarded (not ad hoc); otherwise, or on any doubt,
|
|
34
|
+
the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
|
|
35
|
+
model in an explorer chair, as `route.py --with` does.
|
|
36
|
+
|
|
37
|
+
A retired model (`config/retired.json`) is never called: retiring takes it out
|
|
38
|
+
of every collection, and one a collection still names — as a member, `with`
|
|
39
|
+
or its merger — is skipped and named in `bench.skipped`, its seat going to an
|
|
40
|
+
explorer and the merge to a merger chosen by verdicts.
|
|
41
|
+
|
|
42
|
+
THE LOOP
|
|
43
|
+
--------
|
|
44
|
+
Every seat is recorded in `metrics/offloads.jsonl` as an unjudged offload the
|
|
45
|
+
moment it answers, so the explorer seats rotate even when nobody judges. The
|
|
46
|
+
caller then checks the answers against the real code — agreement between seats
|
|
47
|
+
is not a verdict, they fail together — and records one per call id:
|
|
48
|
+
|
|
49
|
+
python metrics/offload.py verdict <call id> --useful --note "..." \\
|
|
50
|
+
--real "<a finding it got right>" --false "<a claim it made that is untrue>"
|
|
51
|
+
POST /bench/verdict {"call": "<call id>", "useful": true, "note": "...",
|
|
52
|
+
"real": ["..."], "false": ["..."]}
|
|
53
|
+
|
|
54
|
+
The real/false claims are what `suite/mine.py` turns into suite tasks.
|
|
55
|
+
|
|
56
|
+
The verdict replaces the unjudged entry, and route.py's evidence — which
|
|
57
|
+
explorers are due, which models earn a place in a collection — reads it.
|
|
58
|
+
|
|
59
|
+
IMAGES
|
|
60
|
+
------
|
|
61
|
+
A request whose messages carry `image_url` parts (http(s) or data: URLs) goes
|
|
62
|
+
to each seat with its messages as sent, through the built-in client whatever
|
|
63
|
+
BENCH_CLIENT says (`llm -m` takes a prompt). Only models the catalog says take
|
|
64
|
+
images sit (`cannot_see`): a member that cannot, or that the catalog knows
|
|
65
|
+
nothing about, is skipped and named in `bench.skipped` with why; explorers
|
|
66
|
+
are drawn from models that can, under the shape `vision` unless the
|
|
67
|
+
collection sets one; with no seat that can see, the request is refused (400)
|
|
68
|
+
rather than answered blind. Each image counts IMAGE_TOKENS in the size check.
|
|
69
|
+
Wherever the request is written down (the call log, offloads, learned
|
|
70
|
+
limits, error text, the dashboard) an image is a placeholder, never its
|
|
71
|
+
bytes. The merger gets the request as text with those placeholders, plus
|
|
72
|
+
the seats' answers: its job is to reconcile what the seats said, not to look
|
|
73
|
+
again, so any model can merge and a disagreement about the picture is
|
|
74
|
+
reported rather than settled.
|
|
75
|
+
|
|
76
|
+
WHAT IT IS NOT
|
|
77
|
+
--------------
|
|
78
|
+
A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
|
|
79
|
+
exists only for the multi-model work. Seats are called with `llm -m`, exactly
|
|
80
|
+
as offloads always were, or the built-in client (BENCH_CLIENT), so endpoints,
|
|
81
|
+
headers and per-model options all come from the same registry. Full prompts
|
|
82
|
+
and answers go to `runs/gateway/`, gitignored, with the name of the gateway
|
|
83
|
+
key that asked. Bound to 127.0.0.1, like the dashboard (BENCH_BIND and
|
|
84
|
+
BENCH_GATEWAY_PORT change that; beyond this machine it asks for a gateway key,
|
|
85
|
+
metrics/guard.py, which also covers keys on this machine, scopes, caps and
|
|
86
|
+
CORS). The dashboard answers these same routes on its own port.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
from __future__ import annotations
|
|
90
|
+
|
|
91
|
+
import datetime as dt
|
|
92
|
+
import json
|
|
93
|
+
import os
|
|
94
|
+
import pathlib
|
|
95
|
+
import re
|
|
96
|
+
import secrets
|
|
97
|
+
import subprocess
|
|
98
|
+
import sys
|
|
99
|
+
import time
|
|
100
|
+
import urllib.parse
|
|
101
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
102
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
103
|
+
|
|
104
|
+
ROOT = pathlib.Path(__file__).resolve().parent
|
|
105
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
106
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
107
|
+
PORT = int(os.environ.get("BENCH_GATEWAY_PORT") or 8123)
|
|
108
|
+
LOG_DIR = DATA / "runs" / "gateway"
|
|
109
|
+
# The suite's own timeout. Above the slowest real answer seen (383 s); at 900
|
|
110
|
+
# one stuck explorer held a whole request for fifteen minutes.
|
|
111
|
+
SEAT_TIMEOUT = 420
|
|
112
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
113
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
114
|
+
import bench_collections # noqa: E402
|
|
115
|
+
import client # noqa: E402
|
|
116
|
+
import decide # noqa: E402
|
|
117
|
+
import guard # noqa: E402
|
|
118
|
+
import offload # noqa: E402
|
|
119
|
+
import route # noqa: E402
|
|
120
|
+
|
|
121
|
+
# What the shape picker is asked to choose between. Each option carries a
|
|
122
|
+
# description because bare names mislead it: Mercury Decide called "list every
|
|
123
|
+
# place serve.py writes a file" `code` at 0.93 when given names alone.
|
|
124
|
+
SHAPES = {
|
|
125
|
+
"general": "A general question or task that fits none of the other kinds.",
|
|
126
|
+
"design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
|
|
127
|
+
"prose": "Writing or editing documentation or other text.",
|
|
128
|
+
"review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
|
|
129
|
+
"code": "Writing new code or changing existing code.",
|
|
130
|
+
"enumeration": "Listing every place something happens in given code or files.",
|
|
131
|
+
"quick": "A short, bounded task: classify, extract or summarise a small input.",
|
|
132
|
+
}
|
|
133
|
+
CONFIDENT = 0.6
|
|
134
|
+
LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
|
|
135
|
+
# Bytes. A million-token prompt is about 4 MB, and a photo sent as a data URL
|
|
136
|
+
# is 4/3 of its file. 20 MB is the tightest whole-request limit among the big
|
|
137
|
+
# providers (Gemini's for inline data), so what passes here they take too.
|
|
138
|
+
MAX_BODY = 20_000_000
|
|
139
|
+
TOO_BIG = (" Send images as http(s) URLs, or smaller ones: models scale a photo down to about"
|
|
140
|
+
" 1,500-2,000 pixels on its long side anyway.")
|
|
141
|
+
IMAGE_TOKENS = 2_500 # per image, in the size check (estimate_tokens says why)
|
|
142
|
+
IMAGE_URL = re.compile(r"(https?://[^\s/?#]+|data:image/[\w.+-]+;base64,)", re.I)
|
|
143
|
+
DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
|
|
144
|
+
URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
|
|
145
|
+
BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
|
|
146
|
+
HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
|
|
147
|
+
SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
|
|
148
|
+
# A merge that reads as consensus hides the dissent the bench runs on, so the
|
|
149
|
+
# merger is told to keep who said what.
|
|
150
|
+
MERGE_SYSTEM = ("Several models answered the same request. Write one answer to it from theirs: say where"
|
|
151
|
+
" they agreed, where they disagreed, and who said what, naming each model as labelled."
|
|
152
|
+
" Keep every dissent, and do not present a claim as settled because several made it.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def flatten(messages: list[dict]) -> tuple[str, str]:
|
|
156
|
+
"""(system, prompt) for `llm`. A panel question is one prompt; a longer
|
|
157
|
+
conversation is kept as labelled turns rather than silently truncated.
|
|
158
|
+
An image is its placeholder: this text is what gets written down."""
|
|
159
|
+
def text(m):
|
|
160
|
+
c = m.get("content") or ""
|
|
161
|
+
return c if isinstance(c, str) else "\n".join(
|
|
162
|
+
placeholder(_url(p)) if p.get("type") == "image_url" else str(p.get("text") or "")
|
|
163
|
+
for p in c if isinstance(p, dict))
|
|
164
|
+
# Newer OpenAI SDKs send "developer" where older ones send "system".
|
|
165
|
+
lead = ("system", "developer")
|
|
166
|
+
system = "\n\n".join(text(m) for m in messages if m.get("role") in lead)
|
|
167
|
+
turns = [m for m in messages if m.get("role") not in lead]
|
|
168
|
+
if len(turns) == 1:
|
|
169
|
+
return system, text(turns[0])
|
|
170
|
+
return system, "\n\n".join(f"{m.get('role', 'user')}: {text(m)}" for m in turns)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _url(part: dict) -> str:
|
|
174
|
+
"""An image part's URL: {"image_url": {"url": ...}} as OpenAI sends it, or a bare string."""
|
|
175
|
+
u = part.get("image_url")
|
|
176
|
+
u = u.get("url") if isinstance(u, dict) else u
|
|
177
|
+
return u if isinstance(u, str) else ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def images(messages: list[dict]) -> list[str]:
|
|
181
|
+
"""Every image part's URL, in order. ValueError for one no seat could be
|
|
182
|
+
sent: the gateway never fetches an image, the upstream does, so only
|
|
183
|
+
http(s) URLs and inline base64 images pass; and an image in another API's
|
|
184
|
+
shape is refused rather than dropped, which would have a seat answer blind."""
|
|
185
|
+
urls = []
|
|
186
|
+
for m in messages:
|
|
187
|
+
parts = m.get("content")
|
|
188
|
+
for p in parts if isinstance(parts, list) else ():
|
|
189
|
+
kind = p.get("type") if isinstance(p, dict) else None
|
|
190
|
+
if kind in ("image", "input_image"):
|
|
191
|
+
raise ValueError(f'a "{kind}" part is another API\'s shape; send each image as'
|
|
192
|
+
' {"type": "image_url", "image_url": {"url": "<http(s) or data: URL>"}}')
|
|
193
|
+
if kind == "image_url":
|
|
194
|
+
url = _url(p)
|
|
195
|
+
if not IMAGE_URL.match(url):
|
|
196
|
+
raise ValueError("an image_url must be an http(s) URL or a data:image/...;base64, URL,"
|
|
197
|
+
f" not {placeholder(url) if url else 'empty'}")
|
|
198
|
+
urls.append(url)
|
|
199
|
+
return urls
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def placeholder(url: str) -> str:
|
|
203
|
+
"""How an image is written down (the call log, offloads, the dashboard,
|
|
204
|
+
error text, the merger's copy of the request), never its bytes: a data
|
|
205
|
+
URL by its size, an http URL without its query (a signed URL's signature
|
|
206
|
+
is a credential) or a user:password."""
|
|
207
|
+
if url[:5].lower() == "data:":
|
|
208
|
+
return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
|
|
209
|
+
u = urllib.parse.urlsplit(url)
|
|
210
|
+
return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def redact(text: str) -> str:
|
|
214
|
+
"""`text` with each inline image as its placeholder, every http(s) URL
|
|
215
|
+
without its query or user:password, and any other long base64 run as its
|
|
216
|
+
size: an upstream may echo the request in an error, in whatever form, and
|
|
217
|
+
errors are logged, shown and learned from."""
|
|
218
|
+
def bare(m):
|
|
219
|
+
try:
|
|
220
|
+
u = urllib.parse.urlsplit(m.group())
|
|
221
|
+
except ValueError: # an unparseable netloc, such as "[::1"
|
|
222
|
+
return "[url]"
|
|
223
|
+
return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
|
|
224
|
+
text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
|
|
225
|
+
return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cannot_see(alias: str) -> str:
|
|
229
|
+
"""Why an image cannot be put to `alias`, or "" when it can. The catalog
|
|
230
|
+
decides (metrics/catalog.py: the model's input types, your Vision / OCR
|
|
231
|
+
override in its model popup, a capability seen working). A model the
|
|
232
|
+
catalog knows nothing about does not sit either: one that cannot see
|
|
233
|
+
answers anyway, describing a photo it never got, and the override is one
|
|
234
|
+
click."""
|
|
235
|
+
import catalog
|
|
236
|
+
import endpoints
|
|
237
|
+
if endpoints.entry_of(alias).get("api") == "responses":
|
|
238
|
+
return "reached through the Responses API, which the gateway sends no images to yet"
|
|
239
|
+
rec = catalog.info(alias)
|
|
240
|
+
if rec.get("vision"):
|
|
241
|
+
return ""
|
|
242
|
+
if rec.get("vision") is None:
|
|
243
|
+
return "the catalog does not say whether it takes images; tick Vision / OCR in its model popup if it does"
|
|
244
|
+
return f"does not take images (says {rec['sources'].get('vision', 'the catalog')})"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def pick_shape(prompt: str) -> tuple[str, str]:
|
|
248
|
+
"""(shape, how it was chosen). Falls back to general on any doubt."""
|
|
249
|
+
try:
|
|
250
|
+
held = set(json.loads((DATA / "config" / "model-roles.json").read_text(encoding="utf-8"))
|
|
251
|
+
.get("ad_hoc", []))
|
|
252
|
+
except (OSError, ValueError, AttributeError):
|
|
253
|
+
return "general", "config/model-roles.json unreadable"
|
|
254
|
+
picker = next((a for a, d in decide.load().items()
|
|
255
|
+
if "choice" in d["question_types"] and a not in held), None)
|
|
256
|
+
if not picker:
|
|
257
|
+
return "general", "no decision model onboarded"
|
|
258
|
+
question = {"shape": {"type": "choice",
|
|
259
|
+
"instructions": "What kind of task does this prompt ask for?",
|
|
260
|
+
"criteria": SHAPES}}
|
|
261
|
+
try:
|
|
262
|
+
answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
|
|
263
|
+
except Exception as exc: # noqa: BLE001
|
|
264
|
+
return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
|
|
265
|
+
conf = answer.get("confidence", 0)
|
|
266
|
+
if answer.get("choice") not in SHAPES or conf < CONFIDENT:
|
|
267
|
+
return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
|
|
268
|
+
return answer["choice"], f"{picker}, {conf:.2f}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def retired() -> dict[str, dict]:
|
|
272
|
+
"""config/retired.json: alias -> its record (when, why). Retiring a model
|
|
273
|
+
takes it out of every collection (serve.remove_model); this is for a
|
|
274
|
+
collection that still names one -- a hand edit, or one saved before that
|
|
275
|
+
-- so the seat is skipped with a reason instead of failing on the call."""
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads((DATA / "config" / "retired.json").read_text(encoding="utf-8"))
|
|
278
|
+
except (OSError, ValueError):
|
|
279
|
+
return {}
|
|
280
|
+
return data if isinstance(data, dict) else {}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def retired_why(entry: dict) -> str:
|
|
284
|
+
when = str(entry.get("at", ""))[:10] if isinstance(entry, dict) else ""
|
|
285
|
+
return (f"retired from the bench{' on ' + when if when else ''}; take it out of the collection"
|
|
286
|
+
" (Restore in its model popup brings it back)")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def retiring() -> set[str]:
|
|
290
|
+
"""Aliases not to spend an explorer seat on: those on a retiring alert
|
|
291
|
+
(failing their probes; 2026-10-16 both explorers of a bench/core call were
|
|
292
|
+
and both failed), and those cooling down after a rate limit, until the
|
|
293
|
+
wait the provider stated, or 30 minutes (metrics/learn.py)."""
|
|
294
|
+
out: set[str] = set()
|
|
295
|
+
try:
|
|
296
|
+
import alerts
|
|
297
|
+
out |= {a.get("alias", "") for a in alerts.active().values() if a.get("kind") == "retiring"}
|
|
298
|
+
except Exception: # noqa: BLE001
|
|
299
|
+
pass
|
|
300
|
+
try:
|
|
301
|
+
import learn
|
|
302
|
+
out |= set(learn.cooldowns())
|
|
303
|
+
except Exception: # noqa: BLE001
|
|
304
|
+
pass
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None = None) -> dict:
|
|
309
|
+
"""One seat's answer. `messages`: the request as sent, when it carries
|
|
310
|
+
images; it goes through the built-in client whatever BENCH_CLIENT says,
|
|
311
|
+
since `llm -m` takes a prompt, not inline images."""
|
|
312
|
+
cmd = ["llm", "-m", alias] + (["-s", system] if system else [])
|
|
313
|
+
started = time.monotonic()
|
|
314
|
+
if messages is not None or client.backend(alias) == "builtin":
|
|
315
|
+
# No subprocess, and no shared llm log for parallel seats to lock.
|
|
316
|
+
res = client.chat(alias, prompt, system, SEAT_TIMEOUT, messages=messages)
|
|
317
|
+
answer = res["text"]
|
|
318
|
+
error = res["error"] or (f"no answer in {SEAT_TIMEOUT}s" if res["timed_out"] else "")
|
|
319
|
+
else:
|
|
320
|
+
answer, error = _ask_llm(cmd, prompt)
|
|
321
|
+
# A quota or credit notice delivered as the reply's text, with a success
|
|
322
|
+
# exit, is not an answer (suite/run.py PROVIDER_NOTICES).
|
|
323
|
+
from run import provider_notice
|
|
324
|
+
if not error and (notice := provider_notice(answer)):
|
|
325
|
+
error, answer = f"provider notice in a success reply ({notice}): {answer[:200]}", ""
|
|
326
|
+
if messages is not None:
|
|
327
|
+
# Before the error is cut, logged or learned from. ponytail: the answer
|
|
328
|
+
# is not redacted; a seat gets an image as pixels, not as its URL or bytes.
|
|
329
|
+
error = redact(error)
|
|
330
|
+
if error:
|
|
331
|
+
# A stated context window, rate limit or wait is kept (metrics/learn.py);
|
|
332
|
+
# a rate limit also cools the model, so explorer seats skip it.
|
|
333
|
+
try:
|
|
334
|
+
import learn
|
|
335
|
+
learn.record(alias, error, "gateway")
|
|
336
|
+
except Exception: # noqa: BLE001
|
|
337
|
+
pass
|
|
338
|
+
status = "error" if error else ("empty" if not answer else "ok")
|
|
339
|
+
return {"status": status, "answer": answer, "error": error[-500:],
|
|
340
|
+
"seconds": round(time.monotonic() - started, 1)}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _ask_llm(cmd: list[str], prompt: str) -> tuple[str, str]:
|
|
344
|
+
"""(answer, error) from the `llm` CLI."""
|
|
345
|
+
try:
|
|
346
|
+
done = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
|
|
347
|
+
encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
|
|
348
|
+
env={**os.environ, "PYTHONIOENCODING": "utf-8"})
|
|
349
|
+
answer = done.stdout.strip()
|
|
350
|
+
# A non-zero exit is an error even when stderr is silent, unless it is
|
|
351
|
+
# only llm failing to log an answer it printed (client.py).
|
|
352
|
+
failed = done.returncode and not client.llm_log_failed_only(done.stdout, done.stderr)
|
|
353
|
+
error = (done.stderr.strip() or f"llm exited {done.returncode}") if failed else ""
|
|
354
|
+
except subprocess.TimeoutExpired:
|
|
355
|
+
answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
|
|
356
|
+
except Exception as exc: # noqa: BLE001
|
|
357
|
+
# One seat failing to start must not lose the other seats' records.
|
|
358
|
+
answer, error = "", f"{type(exc).__name__}: {exc}"
|
|
359
|
+
return answer, error
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class TooLarge(ValueError):
|
|
363
|
+
"""The prompt does not fit a member's context window (HTTP 413)."""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def estimate_tokens(text: str) -> int:
|
|
367
|
+
"""Characters / 4 plus 10%: tokenizers differ by family, so this errs high.
|
|
368
|
+
The windows it is compared with are corrected by learned 'maximum context
|
|
369
|
+
length' errors (metrics/learn.py). An image adds IMAGE_TOKENS whatever its
|
|
370
|
+
size, which errs high too: OpenAI's, Anthropic's and Gemini's models spend
|
|
371
|
+
up to about 1,600 on one at their usual detail. Qwen and Llama vision
|
|
372
|
+
models can spend more on a very large image; a learned error corrects that."""
|
|
373
|
+
return int(len(text) / 4 * 1.1)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def too_small(needed: int, aliases) -> dict[str, int]:
|
|
377
|
+
"""Aliases whose KNOWN context window is under `needed`, with the window."""
|
|
378
|
+
import catalog
|
|
379
|
+
out = {}
|
|
380
|
+
for a in aliases:
|
|
381
|
+
cw = catalog.info(a).get("context_window")
|
|
382
|
+
if cw and cw < needed:
|
|
383
|
+
out[a] = cw
|
|
384
|
+
return out
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def record_seat(task: str, call: str, label: str, alias: str, r: dict) -> dict:
|
|
388
|
+
"""Log one answer as an unjudged offload, the moment it is in, so explorer
|
|
389
|
+
rotation counts it even if nobody judges; returns the seat as logged."""
|
|
390
|
+
offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
|
|
391
|
+
+ (f": {r['error'][:120]}" if r["error"] else ""), call)
|
|
392
|
+
return {"call": call, "seat": label, "alias": alias, **r}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def merger_candidates(collection: dict, members: list[str], shape: str, large: bool):
|
|
396
|
+
"""Who may merge, best first. The collection's `merger` alone when set;
|
|
397
|
+
else the members with verdicts on this shape, most useful first, then the
|
|
398
|
+
router's primary, then the other members (a fresh install has neither
|
|
399
|
+
verdicts nor suite scores). A generator, so the router is asked only if
|
|
400
|
+
no member with a record fits."""
|
|
401
|
+
if collection.get("merger"):
|
|
402
|
+
yield collection["merger"]
|
|
403
|
+
return
|
|
404
|
+
record: dict[str, list[int]] = {}
|
|
405
|
+
for e in offload.entries():
|
|
406
|
+
# The shape ends each seat's task as run_collection writes it: "(bench/<name>, <shape>)".
|
|
407
|
+
if e["model"] in members and e.get("useful") is not None and e.get("task", "").endswith(f", {shape})"):
|
|
408
|
+
r = record.setdefault(e["model"], [0, 0])
|
|
409
|
+
r[0], r[1] = r[0] + bool(e["useful"]), r[1] + 1
|
|
410
|
+
# Useful verdicts counted, not a ratio, as route.py counts findings: one
|
|
411
|
+
# lucky verdict must not outrank a long good record.
|
|
412
|
+
yield from sorted(record, key=lambda a: (-record[a][0], -record[a][0] / record[a][1]))
|
|
413
|
+
try:
|
|
414
|
+
primary = route.panel(shape, large=large)["primary"]
|
|
415
|
+
except Exception: # noqa: BLE001 -- the members can still merge
|
|
416
|
+
primary = None
|
|
417
|
+
if primary:
|
|
418
|
+
yield primary["model"]
|
|
419
|
+
yield from members
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def merge(collection: dict, members: list[str], shape: str, whole: str, seats: list[dict],
|
|
423
|
+
task: str, panel_id: str) -> tuple[dict | None, str]:
|
|
424
|
+
"""The merger's seat, and why the reply falls back to every answer ("" if
|
|
425
|
+
it does not). The merger reads the request and every answer, so it gets
|
|
426
|
+
the members' size check; a failed merger is still logged like a seat."""
|
|
427
|
+
answered = [s for s in seats if s["status"] == "ok"]
|
|
428
|
+
if not answered:
|
|
429
|
+
return None, "no seat answered"
|
|
430
|
+
text = f"THE REQUEST\n\n{whole}\n\nTHE ANSWERS\n\n" + "\n\n".join(
|
|
431
|
+
f"## {s['seat']} — {s['alias']}\n\n{s['answer']}" for s in answered)
|
|
432
|
+
needed = estimate_tokens(f"{MERGE_SYSTEM}\n\n{text}")
|
|
433
|
+
alias = next((a for a in merger_candidates(collection, members, shape, len(text) > LARGE)
|
|
434
|
+
if not too_small(needed, [a])), None)
|
|
435
|
+
if alias is None:
|
|
436
|
+
return None, f"no merger has the context window for the merge (about {needed:,} tokens)"
|
|
437
|
+
seat = record_seat(task, f"{panel_id}.merger", "merger", alias, ask_seat(alias, MERGE_SYSTEM, text))
|
|
438
|
+
if seat["status"] != "ok":
|
|
439
|
+
return seat, f"the merger {alias} came back {seat['status']}" + (f": {seat['error'][:120]}" if seat["error"] else "")
|
|
440
|
+
return seat, ""
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
|
|
444
|
+
reply: str = "", who: str = "local") -> tuple[str, dict]:
|
|
445
|
+
"""The panel's reply as markdown, and the same seats as data for the
|
|
446
|
+
response's `bench` field. `reply`: the request's mode, "" for the
|
|
447
|
+
collection's own. `who`: the gateway key that asked, for the call log."""
|
|
448
|
+
pictures = images(messages)
|
|
449
|
+
system, prompt = flatten(messages)
|
|
450
|
+
if not prompt.strip():
|
|
451
|
+
raise ValueError("no user message to put to the collection")
|
|
452
|
+
collection = bench_collections.load()[name]
|
|
453
|
+
s = bench_collections.settings()
|
|
454
|
+
raw = collection.get("members") or []
|
|
455
|
+
# The editor dedupes and caps; a hand-edited file may not have.
|
|
456
|
+
members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
|
|
457
|
+
# A RETIRED MODEL IS NOT CALLED: it is no longer registered, so the call
|
|
458
|
+
# could only fail ("Unknown model"). Skipped, and named, before the cap so
|
|
459
|
+
# it frees its seat for the next member or an explorer. Retiring takes it
|
|
460
|
+
# out of the collection, so this is a collection that still names one.
|
|
461
|
+
gone = retired()
|
|
462
|
+
skipped = [{"seat": "member", "alias": a, "why": retired_why(gone[a])} for a in members if a in gone]
|
|
463
|
+
members = [a for a in members if a not in gone][:s["max_calls"]]
|
|
464
|
+
# A member named in `with` is already seated; it must not use up the slot.
|
|
465
|
+
nominated = [a for a in dict.fromkeys(nominated or []) if a not in members]
|
|
466
|
+
skipped += [{"seat": "with", "alias": a, "why": retired_why(gone[a])} for a in nominated
|
|
467
|
+
if a in gone and a not in {k["alias"] for k in skipped}]
|
|
468
|
+
nominated = [a for a in nominated if a not in gone]
|
|
469
|
+
merged = (reply or collection.get("reply") or "all") == "merged"
|
|
470
|
+
if collection.get("merger") in gone:
|
|
471
|
+
if merged:
|
|
472
|
+
skipped.append({"seat": "merger", "alias": collection["merger"],
|
|
473
|
+
"why": retired_why(gone[collection["merger"]]) + "; the merger is chosen by verdicts"})
|
|
474
|
+
collection = {**collection, "merger": ""}
|
|
475
|
+
n_explorers = bench_collections.explorers_for(len(members), s)
|
|
476
|
+
# AN IMAGE GOES ONLY TO A MODEL THAT CAN SEE IT: one that cannot would
|
|
477
|
+
# answer anyway, about a picture it never got. Skipped, and named.
|
|
478
|
+
skipped += [{"seat": seat, "alias": a, "why": why} for seat, group in (("member", members), ("with", nominated))
|
|
479
|
+
for a in group if pictures and (why := cannot_see(a))]
|
|
480
|
+
unseen = {k["alias"] for k in skipped}
|
|
481
|
+
sitting = [a for a in members if a not in unseen]
|
|
482
|
+
nominated = [a for a in nominated if a not in unseen]
|
|
483
|
+
# The system text goes to every seat too, so it counts for shape and size.
|
|
484
|
+
whole = f"{system}\n\n{prompt}".strip()
|
|
485
|
+
# REFUSE WHAT A MEMBER CANNOT READ, so the caller can trim the prompt or
|
|
486
|
+
# pick another collection instead of collecting a seat's context error.
|
|
487
|
+
needed = estimate_tokens(whole) + IMAGE_TOKENS * len(pictures)
|
|
488
|
+
small = too_small(needed, sitting)
|
|
489
|
+
if small:
|
|
490
|
+
windows = ", ".join(f"{a} {cw:,}" for a, cw in sorted(small.items(), key=lambda kv: kv[1]))
|
|
491
|
+
counted = f" (each image counted as {IMAGE_TOKENS:,})" if pictures else ""
|
|
492
|
+
raise TooLarge(f"prompt is about {needed:,} tokens{counted}; too large for {windows} (context"
|
|
493
|
+
f" windows) in bench/{name}. Trim it under {min(small.values()):,} tokens,"
|
|
494
|
+
" or choose a collection whose members have larger windows"
|
|
495
|
+
" (GET /v1/models lists each collection's context_window).")
|
|
496
|
+
if pictures and not collection.get("shape"):
|
|
497
|
+
shape, how = "vision", "the request carries images"
|
|
498
|
+
elif not n_explorers:
|
|
499
|
+
shape, how = collection.get("shape") or "general", "no explorer seats to fill"
|
|
500
|
+
elif collection.get("shape"):
|
|
501
|
+
shape, how = collection["shape"], "set on the collection"
|
|
502
|
+
else:
|
|
503
|
+
shape, how = pick_shape(whole)
|
|
504
|
+
seats = [(f"member{i}", "member", alias) for i, alias in enumerate(sitting, 1)]
|
|
505
|
+
if n_explorers:
|
|
506
|
+
# `with` in the request body is route.py's --with: an explorer seat.
|
|
507
|
+
from run import _registered_names
|
|
508
|
+
avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
|
|
509
|
+
if pictures:
|
|
510
|
+
avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
|
|
511
|
+
p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
|
|
512
|
+
members=sitting, explorers=n_explorers, avoid=avoid)
|
|
513
|
+
seats += [(key, label, p[key]["model"]) for key, label in
|
|
514
|
+
(("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
|
|
515
|
+
if not seats and pictures:
|
|
516
|
+
raise ValueError(f"no seat in bench/{name} can see images, so none is asked rather than answer"
|
|
517
|
+
" blind: " + ("; ".join(f"{k['alias']} {k['why']}" for k in skipped) or "no members")
|
|
518
|
+
+ ", and no model that takes images was free for an explorer seat. Add one to the"
|
|
519
|
+
" collection, or call one whose together.capabilities on GET /v1/models lists vision.")
|
|
520
|
+
if not seats:
|
|
521
|
+
raise LookupError(f"collection {name!r} has no members and no explorer could be seated"
|
|
522
|
+
+ "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
|
|
523
|
+
panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
|
|
524
|
+
model = bench_collections.PREFIX + name
|
|
525
|
+
with ThreadPoolExecutor(max_workers=len(seats)) as pool:
|
|
526
|
+
results = list(pool.map(lambda s: ask_seat(s[2], system, prompt, messages if pictures else None), seats))
|
|
527
|
+
|
|
528
|
+
task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
|
|
529
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
530
|
+
record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
531
|
+
"model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
|
|
532
|
+
"prompt": prompt, "seats": [], **({"skipped": skipped} if skipped else {})}
|
|
533
|
+
out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
|
|
534
|
+
f" explorers · explorer shape {shape} ({how})\n"]
|
|
535
|
+
if skipped:
|
|
536
|
+
out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
|
|
537
|
+
for (key, label, alias), r in zip(seats, results):
|
|
538
|
+
call = f"{panel_id}.{key}"
|
|
539
|
+
record["seats"].append(record_seat(task, call, label, alias, r))
|
|
540
|
+
body = r["answer"] or (f"_error: {r['error'][:300]}_" if r["error"] else
|
|
541
|
+
"_empty reply — usually a token budget, not incapacity_")
|
|
542
|
+
out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
|
|
543
|
+
out.append("---\nRecord a verdict per seat once checked against the code:\n"
|
|
544
|
+
"`POST /bench/verdict` on this same server (see `bench.verdict`), or"
|
|
545
|
+
" `python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
|
|
546
|
+
content = "\n".join(out)
|
|
547
|
+
# Beyond the OpenAI fields, so a caller reads seats without parsing the
|
|
548
|
+
# markdown. Clients that do not know it ignore it.
|
|
549
|
+
bench = {"panel": panel_id, "collection": model, "shape": shape, "shape_by": how,
|
|
550
|
+
"seats": [{k: s[k] for k in SEAT_KEYS} for s in record["seats"]],
|
|
551
|
+
# A path, not a URL: the same server answered, whichever port or
|
|
552
|
+
# proxy the caller came through (the dashboard serves these routes too).
|
|
553
|
+
"verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
|
|
554
|
+
**({"skipped": skipped} if skipped else {})}
|
|
555
|
+
if merged:
|
|
556
|
+
try:
|
|
557
|
+
merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
|
|
558
|
+
except Exception as exc: # noqa: BLE001 -- never lose the panel to the merge
|
|
559
|
+
merger, note = None, f"the merge failed ({type(exc).__name__}: {exc})"
|
|
560
|
+
if merger:
|
|
561
|
+
bench["merger"] = {k: merger[k] for k in SEAT_KEYS}
|
|
562
|
+
record["seats"].append(merger) # the dashboard shows it, verdict and all
|
|
563
|
+
if note:
|
|
564
|
+
bench.update(reply="all", note=note)
|
|
565
|
+
content = f"_Merged reply unavailable: {note}. Every answer follows._\n\n{content}"
|
|
566
|
+
else:
|
|
567
|
+
who = ", ".join(f"{s['alias']} {s['status']}" for s in bench["seats"])
|
|
568
|
+
bench["reply"] = "merged"
|
|
569
|
+
content = (f"{merger['answer']}\n\n---\nMerged by {merger['alias']} (`{merger['call']}`) from:"
|
|
570
|
+
f" {who}. Every seat's answer and call id is in the response's `bench` field.")
|
|
571
|
+
with (LOG_DIR / "calls.jsonl").open("a", encoding="utf-8") as fh:
|
|
572
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
573
|
+
return content, bench
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def models() -> list[str]:
|
|
577
|
+
return [bench_collections.PREFIX + n for n in bench_collections.load()]
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class Handler(BaseHTTPRequestHandler):
|
|
581
|
+
"""The gateway's routes. serve.py's Handler subclasses this to answer them
|
|
582
|
+
on the dashboard's port too, so the checks use the port actually served."""
|
|
583
|
+
who = "-"
|
|
584
|
+
|
|
585
|
+
def _json(self, code: int, data: dict, headers: dict | None = None) -> None:
|
|
586
|
+
body = json.dumps(data).encode()
|
|
587
|
+
self.send_response(code)
|
|
588
|
+
self.send_header("Content-Type", "application/json")
|
|
589
|
+
self.send_header("Content-Length", str(len(body)))
|
|
590
|
+
for k, v in (headers or {}).items():
|
|
591
|
+
self.send_header(k, v)
|
|
592
|
+
self.end_headers()
|
|
593
|
+
self.wfile.write(body)
|
|
594
|
+
|
|
595
|
+
def _error(self, code: int, message: str, headers: dict | None = None) -> None:
|
|
596
|
+
self._json(code, {"error": {"message": message, "type": "bench_gateway", "code": code}}, headers=headers)
|
|
597
|
+
|
|
598
|
+
def end_headers(self):
|
|
599
|
+
# CORS for a browser app on an allowed origin, on the gateway's routes
|
|
600
|
+
# only (metrics/guard.py); Vary, so a cache never hands one origin's
|
|
601
|
+
# answer to another. A request line too broken to parse has no headers.
|
|
602
|
+
headers = getattr(self, "headers", None)
|
|
603
|
+
if headers is not None and guard.gateway_route((getattr(self, "path", "") or "").split("?", 1)[0]) \
|
|
604
|
+
and (origin := guard.cors_origin(headers, self.server.server_port)):
|
|
605
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
606
|
+
self.send_header("Vary", "Origin")
|
|
607
|
+
super().end_headers()
|
|
608
|
+
|
|
609
|
+
def do_OPTIONS(self): # noqa: N802
|
|
610
|
+
"""A browser's CORS preflight: answered on the gateway's routes, for an
|
|
611
|
+
allowed origin only. The dashboard's routes answer none, as before."""
|
|
612
|
+
if not guard.gateway_route(self.path.split("?", 1)[0]):
|
|
613
|
+
return self.send_error(501, f"Unsupported method ({self.command!r})")
|
|
614
|
+
try:
|
|
615
|
+
guard.check(self.headers, self.server.server_port, post=False, max_body=0)
|
|
616
|
+
except guard.Refused as r:
|
|
617
|
+
return self._error(r.status, str(r))
|
|
618
|
+
if not guard.cors_origin(self.headers, self.server.server_port):
|
|
619
|
+
return self._error(403, f"Origin {self.headers.get('Origin') or '(none)'!r} is not allowed;"
|
|
620
|
+
" set BENCH_ALLOWED_ORIGINS or BENCHWARMERS_PUBLIC_URL to add one")
|
|
621
|
+
# The headers it asks for as well, when they are plain names: the
|
|
622
|
+
# OpenAI JS SDK sends X-Stainless-* on every call, and a preflight that
|
|
623
|
+
# refused them would block it. The origin is the boundary, not these.
|
|
624
|
+
asked = (self.headers.get("Access-Control-Request-Headers") or "").strip()
|
|
625
|
+
self.send_response(204)
|
|
626
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
627
|
+
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type"
|
|
628
|
+
+ (f", {asked}" if HEADER_NAMES.fullmatch(asked) else ""))
|
|
629
|
+
self.send_header("Access-Control-Max-Age", "600")
|
|
630
|
+
self.end_headers()
|
|
631
|
+
|
|
632
|
+
def do_GET(self): # noqa: N802
|
|
633
|
+
try:
|
|
634
|
+
guard.check(self.headers, self.server.server_port, post=False, max_body=0)
|
|
635
|
+
self.who = guard.gateway_caller(self.headers)
|
|
636
|
+
except guard.Refused as r:
|
|
637
|
+
return self._error(r.status, str(r), r.headers)
|
|
638
|
+
if self.path.split("?", 1)[0].rstrip("/") in ("/v1/models", "/models"):
|
|
639
|
+
# Beyond the OpenAI fields: what each collection is for, so an
|
|
640
|
+
# orchestrator can choose one by task. Clients ignore extra keys.
|
|
641
|
+
# Each collection twice: as set, and in the other reply mode, so a
|
|
642
|
+
# client that offers only listed models can still pick either.
|
|
643
|
+
s, data = bench_collections.settings(), bench_collections.load()
|
|
644
|
+
rows = []
|
|
645
|
+
for n, c in data.items():
|
|
646
|
+
if not guard.may_call(self.who, n): # a key limited to some collections sees those
|
|
647
|
+
continue
|
|
648
|
+
d = bench_collections.described(n, c, s)
|
|
649
|
+
other = "all" if d["reply"] == "merged" else "merged"
|
|
650
|
+
rows += [{"id": bench_collections.PREFIX + n, "object": "model", "owned_by": "benchwarmers", **d},
|
|
651
|
+
{"id": f"{bench_collections.PREFIX}{n}:{other}", "object": "model",
|
|
652
|
+
"owned_by": "benchwarmers", **d, "reply": other}]
|
|
653
|
+
self._json(200, {"object": "list", "data": rows})
|
|
654
|
+
else:
|
|
655
|
+
self._error(404, "GET /v1/models is the only GET here")
|
|
656
|
+
|
|
657
|
+
def do_POST(self): # noqa: N802
|
|
658
|
+
# Without this any web page could spend model calls, seat ad hoc
|
|
659
|
+
# models through `with`, or write verdicts (metrics/guard.py).
|
|
660
|
+
try:
|
|
661
|
+
length = guard.check(self.headers, self.server.server_port, post=True, max_body=MAX_BODY, cors=True)
|
|
662
|
+
self.who = guard.gateway_caller(self.headers)
|
|
663
|
+
except guard.Refused as r:
|
|
664
|
+
return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers)
|
|
665
|
+
try:
|
|
666
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
667
|
+
except ValueError:
|
|
668
|
+
return self._error(400, "body is not JSON")
|
|
669
|
+
if not isinstance(body, dict):
|
|
670
|
+
return self._error(400, "body must be a JSON object")
|
|
671
|
+
path = self.path.split("?", 1)[0].rstrip("/")
|
|
672
|
+
if path == "/bench/verdict":
|
|
673
|
+
if not isinstance(body.get("useful"), bool):
|
|
674
|
+
return self._error(400, '"useful" must be true or false')
|
|
675
|
+
lists = {k: body.get(k) or [] for k in ("real", "false")}
|
|
676
|
+
if not all(isinstance(v, list) and all(isinstance(s, str) for s in v) for v in lists.values()):
|
|
677
|
+
return self._error(400, '"real" and "false" must be lists of one-sentence claims')
|
|
678
|
+
try:
|
|
679
|
+
e = offload.verdict(str(body.get("call", "")), body["useful"], str(body.get("note") or ""),
|
|
680
|
+
lists["real"], lists["false"])
|
|
681
|
+
except KeyError as exc:
|
|
682
|
+
return self._error(404, exc.args[0])
|
|
683
|
+
return self._json(200, {"ok": True, "recorded": e})
|
|
684
|
+
if path not in ("/v1/chat/completions", "/chat/completions"):
|
|
685
|
+
return self._error(404, "POST /v1/chat/completions or /bench/verdict")
|
|
686
|
+
model = body.get("model") or ""
|
|
687
|
+
if not isinstance(model, str):
|
|
688
|
+
return self._error(400, '"model" must be a collection name such as bench/core')
|
|
689
|
+
model, suffix = bench_collections.split_reply(model)
|
|
690
|
+
name = bench_collections.resolve(model)
|
|
691
|
+
if name is None:
|
|
692
|
+
return self._error(400 if not model else 404, bench_collections.choose_message(model))
|
|
693
|
+
model = bench_collections.PREFIX + name
|
|
694
|
+
messages, nominated, opts = body.get("messages"), body.get("with") or [], body.get("bench") or {}
|
|
695
|
+
if not (isinstance(messages, list) and all(isinstance(m, dict) for m in messages)):
|
|
696
|
+
return self._error(400, '"messages" must be a list of message objects')
|
|
697
|
+
if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
|
|
698
|
+
return self._error(400, '"with" must be a list of model aliases')
|
|
699
|
+
if not isinstance(opts, dict) or opts.get("reply", "all") not in bench_collections.REPLIES:
|
|
700
|
+
return self._error(400, '"bench" must be an object whose "reply" is "all" or "merged"')
|
|
701
|
+
if not guard.may_call(self.who, name):
|
|
702
|
+
allowed = ", ".join(bench_collections.PREFIX + c for c in guard.scope(self.who) or []) or "none"
|
|
703
|
+
return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}")
|
|
704
|
+
try:
|
|
705
|
+
guard.spend(self.who) # counted once it names a collection it may call
|
|
706
|
+
except guard.Refused as r:
|
|
707
|
+
return self._error(r.status, str(r), r.headers)
|
|
708
|
+
try:
|
|
709
|
+
# The body is the most specific, then the suffix, then the collection.
|
|
710
|
+
content, bench = run_collection(name, messages, nominated, opts.get("reply") or suffix, self.who)
|
|
711
|
+
except TooLarge as exc:
|
|
712
|
+
return self._error(413, str(exc))
|
|
713
|
+
except ValueError as exc:
|
|
714
|
+
return self._error(400, str(exc))
|
|
715
|
+
except LookupError as exc:
|
|
716
|
+
return self._error(503, str(exc))
|
|
717
|
+
reply_id, created = f"chatcmpl-{secrets.token_hex(8)}", int(time.time())
|
|
718
|
+
if not body.get("stream"):
|
|
719
|
+
return self._json(200, {
|
|
720
|
+
"id": reply_id, "object": "chat.completion", "created": created, "model": model,
|
|
721
|
+
"choices": [{"index": 0, "finish_reason": "stop",
|
|
722
|
+
"message": {"role": "assistant", "content": content}}], "bench": bench})
|
|
723
|
+
# A panel finishes all at once, so a streaming client gets it as one
|
|
724
|
+
# chunk, merged or not. ponytail: nothing is sent until the panel and
|
|
725
|
+
# the merge are done, so a client or proxy with a shorter idle timeout
|
|
726
|
+
# drops it. Keep-alives mean sending the 200 before the panel runs, so
|
|
727
|
+
# the 413/503 refusals move into the stream; streaming the merge as it
|
|
728
|
+
# is written needs a streaming client.chat (RELEASE-PLAN acceptance 11).
|
|
729
|
+
self.send_response(200)
|
|
730
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
731
|
+
self.end_headers()
|
|
732
|
+
for delta, finish in (({"role": "assistant", "content": content}, None), ({}, "stop")):
|
|
733
|
+
chunk = {"id": reply_id, "object": "chat.completion.chunk", "created": created,
|
|
734
|
+
"model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
|
735
|
+
**({"bench": bench} if finish is None else {})}
|
|
736
|
+
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
|
737
|
+
self.wfile.write(b"data: [DONE]\n\n")
|
|
738
|
+
|
|
739
|
+
def log_request(self, code="-", size="-"): # route, status, who; never a query or a key
|
|
740
|
+
guard.access(self, code)
|
|
741
|
+
|
|
742
|
+
def log_message(self, fmt, *args): # quieter than the default
|
|
743
|
+
sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def main() -> None:
|
|
747
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
748
|
+
address = guard.bind()
|
|
749
|
+
guard.startup(address)
|
|
750
|
+
print(f"bench gateway on http://{address}:{PORT}/v1 — models: {', '.join(models())}")
|
|
751
|
+
ThreadingHTTPServer((address, PORT), Handler).serve_forever()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
if __name__ == "__main__":
|
|
755
|
+
main()
|