agentic-bench 0.2.0__py3-none-any.whl

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.
abench/runner.py ADDED
@@ -0,0 +1,637 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
3
+
4
+ """Command orchestration: what `abench data` and `abench run` actually do.
5
+
6
+ Resolves the dataset source and sampling knobs, plans the workload, drives
7
+ warmup and the measured closed-loop run, then hands results to the report
8
+ layer. Argument *parsing* lives in ``abench.cli``; talking to the server
9
+ lives in ``abench.serve``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import sys
19
+ from typing import Any, Optional
20
+
21
+ from abench import (
22
+ PROFILES,
23
+ build_system_prompt,
24
+ compute_metrics,
25
+ download_tier,
26
+ fetch_server_info,
27
+ flush_cache,
28
+ load_sessions,
29
+ iter_jsonl_sessions,
30
+ measure_text_tokens,
31
+ plan_workload,
32
+ print_report,
33
+ run_closed_loop,
34
+ save_results,
35
+ select_sessions,
36
+ summarize_server_metrics,
37
+ synthesize_sessions,
38
+ warmup,
39
+ wait_for_server_ready,
40
+ write_tier_manifest,
41
+ )
42
+ from abench.core.dataset import TIERS, NormSession
43
+ from abench.serve.endpoints import (
44
+ _detect_backend,
45
+ _fetch_max_model_len,
46
+ _parse_extra_request_body,
47
+ _parse_header_args,
48
+ _resolve_api_url,
49
+ _resolve_base_url,
50
+ _verify_declared_backend,
51
+ load_tokenizer,
52
+ )
53
+
54
+ # ----------------------------------------------------------------------
55
+ # data subcommand (ModelScope download)
56
+ # ----------------------------------------------------------------------
57
+ def cmd_data_entry(args: argparse.Namespace) -> None:
58
+ """`python3 -m abench data`: download a dataset x tier from ModelScope.
59
+
60
+ Data lands under ``<data-path>/<dataset>/<tier>/`` as ``*.jsonl.zst``
61
+ plus a ``manifest.json`` carrying the tier's selection knobs.
62
+ """
63
+ if args.list:
64
+ _list_datasets()
65
+ return
66
+ spec_str = (args.dataset or "").strip().strip("/")
67
+ if not spec_str:
68
+ print(
69
+ "usage: python3 -m abench data <DATASET>[/<TIER>]\n"
70
+ " e.g. python3 -m abench data deepswe/mixed\n"
71
+ " see --list for the available combinations",
72
+ file=sys.stderr,
73
+ )
74
+ sys.exit(1)
75
+ if "/" in spec_str:
76
+ dataset, tier = (p.strip() for p in spec_str.split("/", 1))
77
+ else:
78
+ dataset, tier = spec_str, ""
79
+ if dataset == "random":
80
+ print("random needs no download: python3 -m abench run --dataset-name random", file=sys.stderr)
81
+ sys.exit(1)
82
+ if not tier:
83
+ print(
84
+ "error: specify a tier (see --list): "
85
+ + " / ".join(TIERS),
86
+ file=sys.stderr,
87
+ )
88
+ sys.exit(1)
89
+ key = f"{dataset}/{tier}"
90
+ spec = TIERS.get(key)
91
+ if spec is None:
92
+ print(
93
+ f"unknown dataset/tier {key!r}; see `python3 -m abench data --list`",
94
+ file=sys.stderr,
95
+ )
96
+ sys.exit(1)
97
+ out_dir = os.path.join(args.data_path, dataset, tier)
98
+ files = download_tier(spec, out_dir, verbose=True)
99
+ write_tier_manifest(spec, out_dir, files)
100
+ total_mb = sum(os.path.getsize(p) for p in files) / 1e6
101
+ print(
102
+ f"[data] {key}: {spec.sessions} sessions, {len(files)} file(s), "
103
+ f"{total_mb:.1f} MB -> {out_dir}"
104
+ )
105
+ if os.path.abspath(os.path.expanduser(args.data_path)) == DEFAULT_DATA_ROOT:
106
+ print(f"[data] run with: python3 -m abench run --dataset-name {key}")
107
+ else:
108
+ print(
109
+ f"[data] run with: python3 -m abench run --dataset-name {key} "
110
+ f"--dataset-path {out_dir}"
111
+ )
112
+
113
+
114
+ def _split_dataset_spec(spec: str) -> tuple:
115
+ """Split a `<dataset>/<tier>` spec into (dataset, tier).
116
+
117
+ A bare dataset name defaults to the 'mixed' tier.
118
+ """
119
+ name = (spec or "deepswe/mixed").strip()
120
+ if "/" in name:
121
+ ds, _, tier = name.partition("/")
122
+ return (ds or "deepswe"), (tier or "mixed")
123
+ return name, "mixed"
124
+
125
+
126
+ # Fixed root for the standard layout written by `python3 -m abench data`.
127
+ DEFAULT_DATA_ROOT = os.path.expanduser("~/abench_data")
128
+
129
+
130
+ def _raw_knobs(args: argparse.Namespace) -> dict:
131
+ """Knobs for raw data (no manifest): no band filter, min_turns floor
132
+ of 1, no synthesized system prompt. Explicit CLI knobs still win."""
133
+ return dict(
134
+ min_turns=args.min_turns if args.min_turns is not None else 1,
135
+ max_turns=args.max_turns,
136
+ min_context=args.min_context,
137
+ max_context=args.max_context,
138
+ output_policy="trace",
139
+ system_prompt_len=args.system_prompt_len
140
+ if args.system_prompt_len is not None
141
+ else 0,
142
+ )
143
+
144
+
145
+ def _pool_size(args: argparse.Namespace) -> int:
146
+ """Pool size for both branches: --num-sessions when given, else the
147
+ validated 2 x max-concurrency default.
148
+ Uses an explicit None check so a passed 0 is not silently replaced."""
149
+ if args.num_sessions is not None:
150
+ return args.num_sessions
151
+ size = 2 * max(1, args.max_concurrency)
152
+ print(
153
+ f"[plan] num-sessions unset -> pool = 2 x C = {size} sessions "
154
+ "(pass --num-sessions to override; see the pool-sizing note)"
155
+ )
156
+ return size
157
+
158
+
159
+ def _resolve_data_source(
160
+ dataset_path: str, ds_name: str, tier: str
161
+ ) -> tuple:
162
+ """Resolve --dataset-path to (single_file, data_dir, manifest_dir).
163
+
164
+ Search order: file (use its dir's manifest if present) -> this dir
165
+ holds manifest.json (tier dir) -> descend one level to
166
+ ``<name>/<tier>/manifest.json`` (root dir) -> bare data dir (raw).
167
+ ``manifest_dir`` is None for raw mode; ``data_dir`` is where the
168
+ session files live. Caller treats a nonexistent path as raw with an
169
+ empty load.
170
+ """
171
+ p = os.path.abspath(os.path.expanduser(dataset_path))
172
+ if os.path.isfile(p):
173
+ parent = os.path.dirname(p)
174
+ man = parent if os.path.exists(os.path.join(parent, "manifest.json")) else None
175
+ return p, parent, man
176
+ cand = os.path.join(p, ds_name, tier)
177
+ if os.path.exists(os.path.join(p, "manifest.json")):
178
+ return None, p, p
179
+ if os.path.exists(os.path.join(cand, "manifest.json")):
180
+ return None, cand, cand
181
+ return None, p, None
182
+
183
+
184
+ def _dataset_dir(spec: str, data_path: str) -> str:
185
+ """Standard on-disk location for a `<dataset>/<tier>` spec:
186
+ ``<data-path>/<dataset>/<tier>/``, as written by
187
+ ``python3 -m abench data``."""
188
+ ds, tier = _split_dataset_spec(spec)
189
+ return os.path.join(data_path, ds, tier)
190
+
191
+
192
+ def _resolve_profile_knobs(
193
+ args: argparse.Namespace, data_dir: Optional[str] = None
194
+ ) -> dict:
195
+ """Fill in any knob the user did not set explicitly.
196
+
197
+ Band defaults come from the dataset directory's ``manifest.json``
198
+ (written by ``python3 -m abench data``): a ``band`` entry selects by peak context,
199
+ an absent/null band means no filtering (e.g. full / lmcache tiers).
200
+ Only a directory *without* a manifest falls back to the
201
+ ``PROFILES['mixed']`` preset. Explicit CLI knobs always win.
202
+ """
203
+ defaults: dict = {}
204
+ have_manifest = False
205
+ if data_dir:
206
+ try:
207
+ with open(os.path.join(data_dir, "manifest.json")) as f:
208
+ m = json.load(f)
209
+ have_manifest = True
210
+ except (OSError, ValueError):
211
+ m = {}
212
+ if not isinstance(m, dict):
213
+ m = {}
214
+ band = m.get("band")
215
+ if isinstance(band, (list, tuple)) and len(band) == 2:
216
+ defaults["min_context"] = band[0]
217
+ defaults["max_context"] = band[1]
218
+ for k in ("min_context", "max_context", "min_turns", "system_prompt_len"):
219
+ v = m.get(k)
220
+ if v is not None:
221
+ defaults[k] = v
222
+ if not have_manifest:
223
+ p = PROFILES.get("mixed")
224
+ if p is not None:
225
+ for k in ("min_turns", "min_context", "max_context", "system_prompt_len"):
226
+ defaults.setdefault(k, getattr(p, k, None))
227
+ # A manifest without a band explicitly means "no context filtering";
228
+ # leaving it unset would re-apply the mixed band and drop everything.
229
+ if have_manifest and "min_context" not in defaults:
230
+ defaults["min_context"] = None
231
+ defaults["max_context"] = None
232
+ knobs = dict(
233
+ min_turns=args.min_turns,
234
+ max_turns=args.max_turns,
235
+ min_context=args.min_context,
236
+ max_context=args.max_context,
237
+ output_policy="trace",
238
+ system_prompt_len=args.system_prompt_len,
239
+ )
240
+ for k in ("min_turns", "min_context", "max_context", "system_prompt_len"):
241
+ if knobs[k] is None:
242
+ knobs[k] = defaults.get(k)
243
+ # final fallbacks
244
+ if knobs["min_turns"] is None:
245
+ knobs["min_turns"] = 1
246
+ if knobs["system_prompt_len"] is None:
247
+ knobs["system_prompt_len"] = 0
248
+ return knobs
249
+
250
+
251
+ def _prepare_plan(args: argparse.Namespace, tokenizer) -> tuple:
252
+ """Build the workload plan; returns (plan, knobs, sampling, force_lengths)."""
253
+ routing_fn = None
254
+ if getattr(args, "routing_key", False):
255
+ routing_fn = lambda s: s # noqa: E731 - stable per-session key
256
+
257
+ if args.dataset_name == "random":
258
+ knobs = _resolve_profile_knobs(args)
259
+ try:
260
+ plan = synthesize_sessions(
261
+ num_sessions=_pool_size(args),
262
+ turns=args.turns,
263
+ input_increment=args.input_increment,
264
+ output_len=args.output_len,
265
+ system_prompt_len=(
266
+ args.system_prompt_len
267
+ if args.system_prompt_len is not None
268
+ else 8192
269
+ ),
270
+ gap_policy=args.gap_policy,
271
+ gap_cap_ms=args.gap_cap_ms,
272
+ tokenizer=tokenizer,
273
+ routing_key_fn=routing_fn,
274
+ seed=args.seed,
275
+ )
276
+ except ValueError as e:
277
+ print(f"error: {e}", file=sys.stderr)
278
+ sys.exit(1)
279
+ if not plan:
280
+ print("no sessions synthesized; nothing to run", file=sys.stderr)
281
+ sys.exit(1)
282
+ knobs["system_prompt_len_actual"] = measure_text_tokens(
283
+ plan[0].system_prompt, tokenizer
284
+ )
285
+ knobs["output_policy"] = f"fixed:{args.output_len}"
286
+ print(
287
+ f"[plan] random: {len(plan)} sessions x {args.turns} turns, "
288
+ f"input_increment={args.input_increment}, output_len={args.output_len}, "
289
+ f"gap_policy={args.gap_policy}"
290
+ )
291
+ else:
292
+ ds_name, tier = _split_dataset_spec(args.dataset_name)
293
+ if args.dataset_path:
294
+ single_file, data_dir, manifest_dir = _resolve_data_source(
295
+ args.dataset_path, ds_name, tier
296
+ )
297
+ if manifest_dir:
298
+ knobs = _resolve_profile_knobs(args, manifest_dir)
299
+ print(
300
+ f"[plan] dataset-path: applying manifest.json from "
301
+ f"{manifest_dir} (band/turn/system-prompt defaults active)"
302
+ )
303
+ else:
304
+ knobs = _raw_knobs(args)
305
+ print("[plan] no manifest found; running raw (no band/prompt defaults)")
306
+ if single_file:
307
+ sessions = [
308
+ NormSession.from_dict(raw)
309
+ for raw in iter_jsonl_sessions([single_file])
310
+ ]
311
+ else:
312
+ sessions = load_sessions(data_dir)
313
+ else:
314
+ data_dir = _dataset_dir(args.dataset_name, DEFAULT_DATA_ROOT)
315
+ if not os.path.isdir(data_dir):
316
+ print(
317
+ f"no dataset directory {data_dir}; run "
318
+ f"`python3 -m abench data {ds_name}/{tier}` first, "
319
+ "or point --dataset-path at the data",
320
+ file=sys.stderr,
321
+ )
322
+ sys.exit(1)
323
+ knobs = _resolve_profile_knobs(args, data_dir)
324
+ sessions = load_sessions(data_dir)
325
+ if not sessions:
326
+ print(
327
+ f"no sessions in {args.dataset_path or data_dir}; check the "
328
+ f"path or run `python3 -m abench data {ds_name}/{tier}`",
329
+ file=sys.stderr,
330
+ )
331
+ sys.exit(1)
332
+ # The shared system prompt is built once up front so its measured
333
+ # length can participate in the context band: the band must
334
+ # describe what the server actually sees (recorded peak + injected
335
+ # prompt), not the recorded peak alone.
336
+ system_prompt = (
337
+ build_system_prompt(knobs["system_prompt_len"], tokenizer)
338
+ if knobs["system_prompt_len"]
339
+ else ""
340
+ )
341
+ sys_len = measure_text_tokens(system_prompt, tokenizer)
342
+ knobs["system_prompt_len_actual"] = sys_len
343
+ # --max-context is the MODEL context window (user-facing contract):
344
+ # the injected system prompt consumes part of it, so the recorded-
345
+ # peak cap the band enforces is max_context - sys_len. Surface the
346
+ # derivation instead of making the user subtract.
347
+ if (
348
+ knobs["max_context"] is not None
349
+ and knobs["max_context"] <= sys_len
350
+ ):
351
+ print(
352
+ f"error: --max-context {knobs['max_context']} leaves no room "
353
+ f"for the {sys_len}-token injected system prompt "
354
+ f"(recorded-peak cap would be <= 0)",
355
+ file=sys.stderr,
356
+ )
357
+ sys.exit(1)
358
+ try:
359
+ selected = select_sessions(
360
+ sessions,
361
+ min_turns=knobs["min_turns"],
362
+ max_turns=knobs["max_turns"],
363
+ min_context=knobs["min_context"],
364
+ max_context=knobs["max_context"],
365
+ num_sessions=_pool_size(args),
366
+ seed=args.seed,
367
+ sys_prompt_tokens=sys_len,
368
+ )
369
+ except ValueError as e:
370
+ print(f"error: {e}", file=sys.stderr)
371
+ sys.exit(1)
372
+ if not selected:
373
+ print("no sessions matched the selection knobs", file=sys.stderr)
374
+ sys.exit(1)
375
+ plan = plan_workload(
376
+ sessions=selected,
377
+ system_prompt_len=knobs["system_prompt_len"],
378
+ output_policy=knobs["output_policy"],
379
+ tokenizer=tokenizer,
380
+ routing_key_fn=(lambda s: s.session_id) if args.routing_key else None,
381
+ system_prompt=system_prompt,
382
+ )
383
+ spl_actual = knobs.get("system_prompt_len_actual", 0)
384
+ spl_txt = f"system_prompt_len={knobs['system_prompt_len']}" + (
385
+ f" (actual {spl_actual})"
386
+ if spl_actual != knobs["system_prompt_len"]
387
+ else ""
388
+ )
389
+ ctx_txt = f"context[{knobs['min_context']},{knobs['max_context']}]"
390
+ if knobs["max_context"] is not None:
391
+ ctx_txt += (
392
+ f" (model window: recorded-peak cap "
393
+ f"{knobs['max_context'] - sys_len} after -{sys_len} sys)"
394
+ )
395
+ print(
396
+ f"[plan] {len(plan)} sessions from {args.dataset_name} ({data_dir}), "
397
+ f"{spl_txt}, "
398
+ f"output_policy={knobs['output_policy']}, "
399
+ f"turns[min={knobs['min_turns']},max={knobs['max_turns']}], "
400
+ f"{ctx_txt}"
401
+ )
402
+
403
+ total_turns = sum(len(p.turns) for p in plan)
404
+ total_out = sum(t.max_new_tokens for p in plan for t in p.turns)
405
+ basis = "pinned per-turn lengths"
406
+ total_gap_s = sum(t.pre_gap_ms for p in plan for t in p.turns) / 1000.0
407
+ waves = len(plan) / max(1, args.max_concurrency)
408
+ gap_per_slot_min = total_gap_s / max(1, args.max_concurrency) / 60.0
409
+ print(
410
+ f"[workload] {total_turns} turns, {total_out:,} output tokens "
411
+ f"[{basis}], {total_gap_s / 60:.0f} min of tool waits, "
412
+ f"{waves:.1f} waves over {args.max_concurrency} slots"
413
+ )
414
+ est = " ".join(
415
+ f"{tput}tok/s->{total_out / tput / 60 + gap_per_slot_min:.0f}min"
416
+ for tput in (250, 1000, 4000)
417
+ )
418
+ print(f"[estimate] wall clock at assumed aggregate throughput: {est}")
419
+ print("[estimate] the [progress] lines below carry a measured ETA.")
420
+
421
+ sampling, force_lengths = _build_sampling(args, knobs)
422
+ return plan, knobs, sampling, force_lengths
423
+
424
+
425
+ def _build_sampling(args: argparse.Namespace, knobs: dict) -> tuple:
426
+ """Pinned sampling; returns (sampling dict, ignore_eos)."""
427
+ sampling: dict = {"temperature": args.temperature}
428
+ # Mirrors bench_serving: top_p is only sent when it actually
429
+ # constrains sampling, avoiding divergent edge-case handling of an
430
+ # explicit top_p=1.0 across backends.
431
+ if args.top_p < 1.0:
432
+ sampling["top_p"] = args.top_p
433
+ if args.top_k is not None:
434
+ sampling["top_k"] = args.top_k
435
+ if args.presence_penalty is not None:
436
+ sampling["presence_penalty"] = args.presence_penalty
437
+ try:
438
+ ctk = (
439
+ json.loads(args.chat_template_kwargs)
440
+ if args.chat_template_kwargs
441
+ else None
442
+ )
443
+ except json.JSONDecodeError as e:
444
+ raise SystemExit(f"bad --chat-template-kwargs: {e}")
445
+ if ctk:
446
+ sampling["chat_template_kwargs"] = ctk
447
+ # Lengths are always pinned (trace records / fixed:K), so ignore_eos is
448
+ # always on: without it a natural EOS would cut turns short.
449
+ force_lengths = True
450
+ extras = f" top_p={args.top_p}" if args.top_p < 1.0 else ""
451
+ print(
452
+ f"[sampling] temperature={args.temperature}{extras}"
453
+ f" ignore_eos={force_lengths} chat_template_kwargs={ctk or '{}'}"
454
+ )
455
+ return sampling, force_lengths
456
+
457
+
458
+ def _execute_point(
459
+ args: argparse.Namespace,
460
+ plan,
461
+ knobs: dict,
462
+ tokenizer,
463
+ backend: str = "sglang",
464
+ max_model_len: Optional[int] = None,
465
+ ) -> Any:
466
+ """Flush, warm up, replay, and compute metrics for one run."""
467
+ api_url = _resolve_api_url(args.base_url)
468
+ sampling, force_lengths = _build_sampling(args, knobs)
469
+ extra_headers = _parse_header_args(args.header)
470
+ extra_request_body = _parse_extra_request_body(args.extra_request_body)
471
+
472
+ async def _main() -> tuple:
473
+ if args.flush_cache:
474
+ await flush_cache(args.base_url, backend=backend)
475
+ if args.warmup_requests > 0:
476
+ await warmup(
477
+ pool=plan,
478
+ api_url=api_url,
479
+ model=args.model,
480
+ tokenizer=tokenizer,
481
+ n=args.warmup_requests,
482
+ disable_stream=args.disable_stream,
483
+ extra_headers=extra_headers,
484
+ extra_request_body=extra_request_body,
485
+ backend=backend,
486
+ max_duration_s=args.max_duration,
487
+ )
488
+ return await run_closed_loop(
489
+ pool=plan,
490
+ api_url=api_url,
491
+ model=args.model,
492
+ max_concurrency=args.max_concurrency,
493
+ tokenizer=tokenizer,
494
+ content_mode="trace",
495
+ disable_stream=args.disable_stream,
496
+ duration_s=args.duration,
497
+ respect_gaps=not args.no_gaps,
498
+ gap_scale=args.gap_scale,
499
+ worker_header=args.worker_header,
500
+ verbose=args.verbose,
501
+ progress_interval=args.progress_interval,
502
+ extra_body=sampling,
503
+ ignore_eos=force_lengths,
504
+ turn_timeout_s=args.turn_timeout,
505
+ metrics_interval=args.metrics_interval,
506
+ session_timeout_s=args.session_timeout,
507
+ extra_headers=extra_headers,
508
+ extra_request_body=extra_request_body,
509
+ backend=backend,
510
+ max_duration_s=args.max_duration,
511
+ disable_tqdm=args.disable_tqdm,
512
+ max_model_len=max_model_len,
513
+ )
514
+
515
+ results, server_snapshots = asyncio.run(_main())
516
+ metrics = compute_metrics(
517
+ sessions=results,
518
+ target_concurrency=args.max_concurrency,
519
+ steady_frac=args.steady_frac,
520
+ steady_only=True,
521
+ )
522
+ summarize_server_metrics(metrics, server_snapshots, backend=backend)
523
+ return metrics, results, server_snapshots
524
+
525
+
526
+ def cmd_run(args: argparse.Namespace) -> None:
527
+ if args.dataset_name == "random" and args.dataset_path:
528
+ print(
529
+ "error: --dataset-name random synthesizes locally and reads no "
530
+ "files; drop --dataset-path (or pick a real dataset name)",
531
+ file=sys.stderr,
532
+ )
533
+ sys.exit(1)
534
+ base_url = _resolve_base_url(args)
535
+ args.base_url = base_url
536
+ asyncio.run(wait_for_server_ready(base_url, args.ready_check_timeout_sec))
537
+ backend = _detect_backend(base_url, args.backend)
538
+ print(f"[backend] {backend}")
539
+ if args.backend != "auto":
540
+ _verify_declared_backend(base_url, backend)
541
+ if args.flush_cache and backend == "openai":
542
+ print(
543
+ "[flush_cache] skipped: the openai backend has no "
544
+ "cache-flush endpoint"
545
+ )
546
+ args.flush_cache = False
547
+ max_model_len = _fetch_max_model_len(base_url)
548
+ if max_model_len:
549
+ print(
550
+ f"[ctx-guard] model context {max_model_len} tokens; sessions "
551
+ "whose estimated prompt + pinned completion exceed it stop early"
552
+ )
553
+ else:
554
+ print("[ctx-guard] disabled: /v1/models reports no max_model_len")
555
+ # /model_info is an SGLang endpoint; for other backends the tokenizer
556
+ # resolves from --model (local dir) or --tokenizer instead.
557
+ tokenizer = load_tokenizer(
558
+ args.tokenizer or args.model,
559
+ base_url if backend == "sglang" else None,
560
+ )
561
+ plan, knobs, _, _ = _prepare_plan(args, tokenizer)
562
+ metrics, results, server_snapshots = _execute_point(
563
+ args, plan, knobs, tokenizer, backend, max_model_len=max_model_len
564
+ )
565
+ if backend != "sglang":
566
+ # host/device cache layering is an sglang-only concept
567
+ # (not measurable != zero). Capacity and eviction are measured
568
+ # for vllm too; openai has no /metrics so both stay None via the
569
+ # empty-snapshot no-op in summarize_server_metrics.
570
+ metrics.host_cache_ratio = None
571
+ metrics.host_share_of_cache = None
572
+
573
+ server_info = (
574
+ asyncio.run(fetch_server_info(args.base_url))
575
+ if backend == "sglang"
576
+ else None
577
+ )
578
+
579
+ config = {
580
+ "model": args.model,
581
+ "backend": backend,
582
+ "dataset_name": args.dataset_name,
583
+ "dataset_path": args.dataset_path,
584
+ "tag": args.tag,
585
+ "system_prompt_len": knobs["system_prompt_len"],
586
+ "system_prompt_len_actual": knobs.get("system_prompt_len_actual", 0),
587
+ "max_model_len": max_model_len,
588
+ "temperature": args.temperature,
589
+ "top_p": args.top_p,
590
+ "chat_template_kwargs": args.chat_template_kwargs,
591
+ "ignore_eos": True,
592
+ "min_context": knobs["min_context"],
593
+ "max_context": knobs["max_context"],
594
+ "output_details": args.output_details,
595
+ "max_concurrency": args.max_concurrency,
596
+ "num_sessions": metrics.num_sessions,
597
+ "duration_s": args.duration,
598
+ "respect_gaps": not args.no_gaps,
599
+ "gap_scale": args.gap_scale,
600
+ "seed": args.seed,
601
+ }
602
+ print_report(metrics, config)
603
+ # bench_serving habit: results always land on disk. Without an explicit
604
+ # --output-file, pick a dated, collision-resistant default name.
605
+ output_file = args.output_file
606
+ if not output_file:
607
+ import datetime as _dt
608
+
609
+ today = _dt.datetime.now().strftime("%m%d_%H%M%S")
610
+ ds_label = args.dataset_name.replace("/", "-")
611
+ output_file = f"{backend}_{today}_{ds_label}.jsonl"
612
+ save_results(
613
+ results,
614
+ metrics,
615
+ output_file,
616
+ config,
617
+ server_snapshots,
618
+ server_info=server_info,
619
+ include_details=args.output_details,
620
+ )
621
+ print(f"[save] appended to {output_file}")
622
+
623
+
624
+ # ----------------------------------------------------------------------
625
+ # data listing
626
+ # ----------------------------------------------------------------------
627
+ def _list_datasets() -> None:
628
+ """Print the dataset x tier combinations `python3 -m abench data` fetches."""
629
+ print("Available dataset x tier combinations (from ModelScope):")
630
+ for spec in TIERS.values():
631
+ size_mb = sum(f[1] for f in spec.files) / 1e6
632
+ print(
633
+ f" {spec.name:<16s} {spec.sessions:>6d} sessions "
634
+ f"({size_mb:.0f} MB)"
635
+ )
636
+ print()
637
+ print("'random' needs no download: python3 -m abench run --dataset-name random")
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
3
+
4
+ """Engine-facing layer: everything that talks to the server under test.
5
+
6
+ ``endpoints`` discovery, auth, readiness, tokenizer resolution, cache flush
7
+ ``telemetry`` /metrics scraping and Prometheus parsing
8
+ ``report`` terminal report and JSONL persistence
9
+ """
10
+
11
+ from abench.serve.endpoints import (
12
+ fetch_server_info,
13
+ flush_cache,
14
+ get_auth_headers,
15
+ load_tokenizer,
16
+ server_root,
17
+ wait_for_server_ready,
18
+ )
19
+ from abench.serve.report import metrics_to_dict, print_report, save_results
20
+
21
+ __all__ = [
22
+ "fetch_server_info",
23
+ "flush_cache",
24
+ "get_auth_headers",
25
+ "load_tokenizer",
26
+ "server_root",
27
+ "wait_for_server_ready",
28
+ "metrics_to_dict",
29
+ "print_report",
30
+ "save_results",
31
+ ]