@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.
Files changed (96) hide show
  1. package/AGENTS.md +147 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +272 -271
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -275
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -155
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -72
  12. package/cli.py +666 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +755 -712
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -0
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +295 -295
  22. package/metrics/client.py +394 -373
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +152 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/guard.py +617 -592
  30. package/metrics/learn.py +221 -221
  31. package/metrics/litellm_hub.py +206 -206
  32. package/metrics/model_roles.py +84 -84
  33. package/metrics/offload.py +166 -166
  34. package/metrics/owui.py +205 -205
  35. package/metrics/paths.py +60 -60
  36. package/metrics/route.py +579 -574
  37. package/metrics/scheduler.py +275 -275
  38. package/metrics/sources.py +167 -167
  39. package/metrics/wrong.py +144 -144
  40. package/package.json +68 -64
  41. package/perf/README.md +89 -89
  42. package/perf/run.py +261 -261
  43. package/scripts/auto_retire.py +367 -367
  44. package/scripts/check.py +108 -104
  45. package/scripts/check_deprecations.py +378 -378
  46. package/scripts/check_themes.py +103 -103
  47. package/scripts/init.py +124 -124
  48. package/scripts/list_served.py +78 -78
  49. package/scripts/notice.py +78 -78
  50. package/scripts/onboard.py +401 -331
  51. package/scripts/provider_tags.py +129 -129
  52. package/scripts/reprefix.py +136 -136
  53. package/scripts/sync_config.py +105 -105
  54. package/scripts/test_auth.py +255 -255
  55. package/scripts/test_cli.py +189 -0
  56. package/scripts/test_client.py +233 -233
  57. package/scripts/test_connect.py +222 -222
  58. package/scripts/test_first_run.py +180 -180
  59. package/scripts/test_hubs.py +328 -328
  60. package/scripts/test_launcher.py +67 -0
  61. package/scripts/test_llm_log.py +85 -0
  62. package/scripts/test_mcp.py +178 -0
  63. package/scripts/test_merged.py +214 -193
  64. package/scripts/test_no_owui.py +291 -291
  65. package/scripts/test_owui.py +90 -90
  66. package/scripts/test_serve_edit.py +448 -386
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3662
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1040
  73. package/suite/seed.py +421 -421
  74. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  75. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  76. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  77. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  78. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  79. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  80. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  81. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  82. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  83. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  84. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  85. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  86. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  87. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  88. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  89. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  90. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  91. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  92. package/ui/bench.css +430 -424
  93. package/ui/bench.js +1756 -1555
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
@@ -1,386 +1,448 @@
1
- """Exercise serve.change_model against a COPY of the config, never the real one.
2
-
3
- python scripts/test_serve_edit.py
4
-
5
- Each rule the popup enforces is tried once and must refuse; then a role change
6
- and a rename are applied to the copy and checked. The llm sync is not run:
7
- serve.ROOT points at the copy, which has no scripts/sync_config.py, so the
8
- sync reports that it could not run. Alerts and endpoints point at copies too,
9
- because removing a model clears its alerts and a regressed endpoint check
10
- would write the endpoint file.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import json
16
- import os
17
- import pathlib
18
- import shutil
19
- import sys
20
- import tempfile
21
-
22
- ROOT = pathlib.Path(__file__).resolve().parent.parent
23
- sys.path.insert(0, str(ROOT / "metrics"))
24
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
25
- sys.path.insert(0, str(ROOT))
26
- import serve # noqa: E402
27
-
28
-
29
- def main() -> int:
30
- tmp = pathlib.Path(tempfile.mkdtemp(prefix="bench-edit-"))
31
- cfg = tmp / "config"
32
- cfg.mkdir()
33
- for name in ("model-roles.json", "extra-openai-models.yaml",
34
- "alias-history.json", "model-options.json", "provider-tags.json"):
35
- shutil.copy(DATA / "config" / name, cfg / name)
36
- (tmp / "scripts").mkdir()
37
- (tmp / "metrics").mkdir()
38
- shutil.copy(ROOT / "metrics" / "aliases.py", tmp / "metrics" / "aliases.py")
39
- # point serve at the copy
40
- serve.ROOT = tmp
41
- serve.DATA = tmp # the data paths serve builds inline follow this one
42
- serve.ROLES_PATH = cfg / "model-roles.json"
43
- serve.YAML_PATH = cfg / "extra-openai-models.yaml"
44
- serve.HISTORY_PATH = cfg / "alias-history.json"
45
- serve.OPTIONS_PATH = cfg / "model-options.json"
46
- serve.CHANGES_PATH = cfg / "changes.jsonl"
47
- serve.RETIRED_PATH = cfg / "retired.json"
48
- import aliases
49
- aliases.HISTORY = cfg / "alias-history.json"
50
- import alerts
51
- shutil.copy(DATA / "metrics" / "alerts.json", tmp / "metrics" / "alerts.json")
52
- alerts.FILE = tmp / "metrics" / "alerts.json"
53
- # Decision models too: a rename or removal of one writes decide.FILE, and
54
- # which model the rename picks depends on the data (sorted first).
55
- import decide
56
- if (DATA / "config" / "decision-models.json").exists():
57
- shutil.copy(DATA / "config" / "decision-models.json", cfg / "decision-models.json")
58
- decide.FILE = cfg / "decision-models.json"
59
- shutil.copy(DATA / "config" / "endpoints.json", cfg / "endpoints.json")
60
- serve.endpoints.FILE = cfg / "endpoints.json"
61
- serve.endpoints.YAML = serve.YAML_PATH
62
- real = {p: p.read_bytes() for p in (DATA / "metrics" / "alerts.json", DATA / "config" / "endpoints.json")}
63
-
64
- d = serve.model_directory()
65
- some = sorted(d)[0]
66
- retired = next(iter(json.loads((cfg / "alias-history.json").read_text(encoding="utf-8"))))
67
- other = next(a for a in d if a != some)
68
- failures = 0
69
-
70
- def expect_refused(label, **kw):
71
- nonlocal failures
72
- r = serve.change_model(**kw)
73
- ok = (not r["ok"]) and r["errors"] and not r["changed"]
74
- print(("ok " if ok else "FAIL ") + label + ("" if ok else f" -> {r}"))
75
- failures += 0 if ok else 1
76
-
77
- expect_refused("unknown alias", alias="no-such-model", new_alias="x", new_role="general")
78
- expect_refused("nothing to change", alias=some, new_alias=some, new_role=d[some]["role"])
79
- expect_refused("unknown role", alias=some, new_alias=some, new_role="wizard")
80
- expect_refused("alias with a space", alias=some, new_alias="bad name", new_role=d[some]["role"])
81
- expect_refused("alias taken", alias=some, new_alias=other, new_role=d[some]["role"])
82
- expect_refused("retired alias reused", alias=some, new_alias=retired, new_role=d[some]["role"])
83
- expect_refused("case-only collision", alias=some, new_alias=other.upper(), new_role=d[some]["role"])
84
-
85
- # role change
86
- new_role = "code" if d[some]["role"] != "code" else "general"
87
- r = serve.change_model(some, some, new_role)
88
- ok = r["ok"] and serve.model_directory()[some]["role"] == new_role
89
- print(("ok " if ok else "FAIL ") + f"role change {some}: {d[some]['role']} -> {new_role}")
90
- failures += 0 if ok else 1
91
-
92
- # ad hoc: a flag, not a role — setting it leaves the role alone
93
- expect_refused("ad_hoc not a bool", alias=some, new_alias=some, new_role=None,
94
- new_ad_hoc="yes")
95
- r = serve.change_model(some, some, None, new_ad_hoc=True)
96
- ok = (r["ok"] and serve.model_directory()[some]["ad_hoc"] is True
97
- and serve.model_directory()[some]["role"] == new_role)
98
- print(("ok " if ok else "FAIL ") + f"ad hoc on {some}, role kept")
99
- failures += 0 if ok else 1
100
- expect_refused("ad hoc already set", alias=some, new_alias=some, new_role=None,
101
- new_ad_hoc=True)
102
-
103
- # rename
104
- r = serve.change_model(some, some + "-renamed", None)
105
- d2 = serve.model_directory()
106
- hist = json.loads((cfg / "alias-history.json").read_text(encoding="utf-8"))
107
- roles = json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))["models"]
108
- held = json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))["ad_hoc"]
109
- ok = (r["ok"] and some not in d2 and some + "-renamed" in d2
110
- and some + "-renamed" in held and some not in held
111
- and d2[some + "-renamed"]["served"] == d[some]["served"]
112
- and hist.get(some) == some + "-renamed"
113
- and roles.get(some + "-renamed") == new_role and some not in roles
114
- and aliases.current(some) == some + "-renamed")
115
- print(("ok " if ok else "FAIL ") + f"rename {some} -> {some}-renamed; sync: {r['sync'][:60]}")
116
- failures += 0 if ok else 1
117
- # and the old name can never come back
118
- expect_refused("old name refused after rename", alias=some + "-renamed",
119
- new_alias=some, new_role=None)
120
- lines = (cfg / "changes.jsonl").read_text(encoding="utf-8").splitlines()
121
- print(("ok " if len(lines) == 3 else "FAIL ") + f"{len(lines)} changes logged")
122
- failures += 0 if len(lines) == 3 else 1
123
-
124
- # removal
125
- victim = some + "-renamed"
126
- served = d2[victim]["served"]
127
- for label, kw in (("remove: alias not typed", dict(typed="nope", reason="retired by provider")),
128
- ("remove: no reason", dict(typed=victim, reason="")),
129
- ("remove: unknown alias", dict(typed="x", reason="whatever it is"))):
130
- r = serve.remove_model("no-such" if "unknown" in label else victim, **kw)
131
- ok = not r["ok"] and not r["changed"]
132
- print(("ok " if ok else "FAIL ") + label); failures += 0 if ok else 1
133
- before = len(serve.registered())
134
- r = serve.remove_model(victim, victim, "retired by provider on 2026-09-15")
135
- d3 = serve.model_directory()
136
- retired = json.loads((cfg / "retired.json").read_text(encoding="utf-8"))
137
- yaml = (cfg / "extra-openai-models.yaml").read_text(encoding="utf-8")
138
- ok = (r["ok"] and victim not in d3 and len(serve.registered()) == before - 1
139
- and retired[victim]["served"] == served and f"model_id: {victim}\n" not in yaml
140
- and victim not in json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))["models"]
141
- and victim not in json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))["ad_hoc"]
142
- and served not in serve.registered())
143
- print(("ok " if ok else "FAIL ") + f"removed {victim}; registry {before} -> {len(serve.registered())}")
144
- failures += 0 if ok else 1
145
- expect_refused("retired name never reused", alias=other, new_alias=victim, new_role=None)
146
- # restore: same alias back, out of retired.json, role and ad hoc kept
147
- r = serve.restore_model(victim)
148
- retired = json.loads((cfg / "retired.json").read_text(encoding="utf-8"))
149
- roles = json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))
150
- ok = (r["ok"] and serve.registered().get(served) == victim and victim not in retired
151
- and roles["models"].get(victim) == new_role and victim in roles["ad_hoc"])
152
- print(("ok " if ok else "FAIL ") + f"restored {victim}" + ("" if ok else f" -> {r}"))
153
- failures += 0 if ok else 1
154
- ok = not serve.restore_model(victim)["ok"]
155
- print(("ok " if ok else "FAIL ") + "restore refused when not retired"); failures += 0 if ok else 1
156
- serve.remove_model(victim, victim, "retired again for the onboarding checks")
157
- # onboarding refusals (nothing is started)
158
- for label, kw in (("onboard: empty", dict(served="")),
159
- ("onboard: spaces", dict(served="vendor/model x")),
160
- ("onboard: already registered", dict(served=d3[other]["served"])),
161
- ("onboard: retired served model", dict(served=served)),
162
- ("onboard: shell characters", dict(served='m" & calc & "')),
163
- ("onboard: unknown endpoint", dict(served="vendor/m", endpoint="nope"))):
164
- r = serve.start_onboard(**kw)
165
- ok = not r["ok"] and "log" not in r
166
- print(("ok " if ok else "FAIL ") + label + ("" if ok else f" -> {r}")); failures += 0 if ok else 1
167
- print(("ok " if serve.onboard_status("../etc/passwd")["ok"] is False else "FAIL ") + "onboard log name guarded")
168
- # endpoints: refused before any network call or write
169
- for label, args in (("endpoint: bad name", ("Bad Name", "https://x.example/v1", "")),
170
- ("endpoint: bad url", ("good", "ftp://x", "")),
171
- ("endpoint: default not removable", None)):
172
- r = serve.save_endpoint(*args) if args else serve.remove_endpoint(serve.endpoints.default())
173
- ok = not r["ok"]
174
- print(("ok " if ok else "FAIL ") + label); failures += 0 if ok else 1
175
-
176
- # Removing an endpoint with a model on it: refused unless asked, then the
177
- # model retires with it, and Restore will not move it to another endpoint.
178
- eps = json.loads((cfg / "endpoints.json").read_text(encoding="utf-8"))
179
- eps["edit-test-ep"] = {"base": "https://edit-test.example/v1", "key": "edit-test-ep"}
180
- (cfg / "endpoints.json").write_text(json.dumps(eps), encoding="utf-8")
181
- serve.YAML_PATH.write_text(serve.read(serve.YAML_PATH).rstrip("\n") + "\n"
182
- + serve.endpoints.yaml_block("edit-test-m", "vendor/edit-test", "edit-test-ep"),
183
- encoding="utf-8")
184
- refused = serve.remove_endpoint("edit-test-ep")
185
- r = serve.remove_endpoint("edit-test-ep", with_models=True)
186
- back = serve.restore_model("edit-test-m")
187
- ok = (not refused["ok"] and "1 model" in refused["errors"][0]
188
- and r["ok"] and r["retired"] == ["edit-test-m"] and "edit-test-ep" not in serve.endpoints.load()
189
- and "edit-test-m" in json.loads((cfg / "retired.json").read_text(encoding="utf-8"))
190
- and "edit-test-m" not in serve.model_directory()
191
- and not back["ok"] and "was removed" in back["errors"][0])
192
- print(("ok " if ok else "FAIL ") + "endpoint removed with its model; restore refused without it"
193
- + ("" if ok else f" -> {refused} {r} {back}"))
194
- failures += 0 if ok else 1
195
-
196
- # Call options: max_tokens set, refused above the model's max output or as
197
- # a fraction or for no model, then cleared; the llm CLI's copy is stubbed.
198
- told, real_info = [], serve.catalog.info
199
- serve._llm_option = lambda alias, name, value: told.append((alias, name, value)) or "stub"
200
- serve.catalog.info = lambda a: {**real_info(a), "max_output": 50_000}
201
- chat = next(a for a, m in sorted(serve.model_directory().items()) if m.get("kind") != "decision")
202
- before = json.loads(serve.OPTIONS_PATH.read_text(encoding="utf-8"))
203
- try:
204
- put = serve.save_model_options(chat, 31_337)
205
- mid = json.loads(serve.OPTIONS_PATH.read_text(encoding="utf-8"))
206
- refused = [serve.save_model_options(chat, 60_000), serve.save_model_options(chat, 12.5),
207
- serve.save_model_options("no-such-model", 100), serve.save_model_options(chat, True)]
208
- gone = serve.save_model_options(chat, None)
209
- after = json.loads(serve.OPTIONS_PATH.read_text(encoding="utf-8"))
210
- finally:
211
- serve.catalog.info = real_info
212
- ok = (put["ok"] and mid[chat]["max_tokens"] == 31_337 and not any(r["ok"] for r in refused)
213
- and gone["ok"] and "max_tokens" not in after.get(chat, {}) and after.get("_why") == before.get("_why")
214
- and told == [(chat, "max_tokens", 31_337), (chat, "max_tokens", None)])
215
- print(("ok " if ok else "FAIL ") + f"call options for {chat}" + ("" if ok else f" -> {put} {refused} {gone} {told}"))
216
- failures += 0 if ok else 1
217
-
218
- # The scheduler: a mode and the jobs turned off, nothing else written.
219
- import scheduler
220
- scheduler.CONFIG = cfg / "scheduler.json"
221
- bad = [serve.save_scheduler("sometimes", {}), serve.save_scheduler("off", {"nope": True})]
222
- good = serve.save_scheduler("log-only", {"perf": False, "suite": True})
223
- conf = scheduler.settings()
224
- ok = (not any(r["ok"] for r in bad) and good["ok"]
225
- and conf == {"mode": "log-only", "jobs": {"perf": False}} and "perf" not in scheduler.enabled(conf))
226
- print(("ok " if ok else "FAIL ") + "scheduler mode and jobs" + ("" if ok else f" -> {bad} {good} {conf}"))
227
- failures += 0 if ok else 1
228
-
229
- # The model client: chosen here unless BENCH_CLIENT is set; llm only where installed.
230
- import client as model_client
231
- model_client.CHOICE = cfg / "client.json"
232
- env, real_which = os.environ.pop("BENCH_CLIENT", None), shutil.which
233
- try:
234
- shutil.which = lambda name: "/usr/bin/" + name # llm installed: the default is llm
235
- first = model_client.chosen()
236
- shutil.which = lambda name: None # not installed (npm, a container)
237
- bare = model_client.chosen()
238
- shutil.which = real_which
239
- made = serve.save_client("builtin")
240
- picked = (model_client.chosen(), model_client.backend())
241
- os.environ["BENCH_CLIENT"] = "llm"
242
- pinned = (model_client.chosen(), serve.save_client("builtin")["ok"])
243
- del os.environ["BENCH_CLIENT"]
244
- shutil.which = lambda name: None
245
- no_llm = serve.save_client("llm")["ok"]
246
- shutil.which = real_which
247
- unknown = serve.save_client("curl")["ok"]
248
- finally:
249
- shutil.which = real_which
250
- os.environ.pop("BENCH_CLIENT", None)
251
- if env is not None:
252
- os.environ["BENCH_CLIENT"] = env
253
- ok = (first == ("llm", "default") and bare == ("builtin", "default") and made["ok"] and picked == (("builtin", "settings"), "builtin")
254
- and pinned == (("llm", "environment"), False) and not no_llm and not unknown)
255
- print(("ok " if ok else "FAIL ") + "model client choice" + ("" if ok else f" -> {first} {made} {picked} {pinned}"))
256
- failures += 0 if ok else 1
257
-
258
- # Free only in their own app: only what differs from the built-in is written; bad input refused.
259
- import sources
260
- real_conn, endpoints_mod = serve.endpoints.CONNECTIONS, serve.endpoints
261
- endpoints_mod.CONNECTIONS = cfg / "connections.json"
262
- try:
263
- endpoints_mod.CONNECTIONS.write_text('{"paid": ["x"]}', encoding="utf-8")
264
- off = serve.save_own_client_free({"opencode": False, "opencode-go": True})["ok"]
265
- wrote = json.loads(endpoints_mod.CONNECTIONS.read_text(encoding="utf-8"))
266
- seen_off = sources.free_in_own_client_only()["opencode"]
267
- back = serve.save_own_client_free({"opencode": True, "opencode-go": True})["ok"]
268
- cleared = json.loads(endpoints_mod.CONNECTIONS.read_text(encoding="utf-8"))
269
- refused = (serve.save_own_client_free({"no-such-provider-x": True})["ok"],
270
- serve.save_own_client_free({"opencode": "no"})["ok"], serve.save_own_client_free(["opencode"])["ok"])
271
- card = serve.own_client_card()
272
- finally:
273
- endpoints_mod.CONNECTIONS = real_conn
274
- ok = (off and wrote == {"paid": ["x"], "free_in_own_client_only": {"opencode": False}} and seen_off is False
275
- and back and cleared == {"paid": ["x"]} and refused == (False, False, False)
276
- and "value='opencode'" in card and "oc-save" in card)
277
- print(("ok " if ok else "FAIL ") + "free only in their own app" + ("" if ok else f" -> {wrote} {cleared} {refused}"))
278
- failures += 0 if ok else 1
279
-
280
- # The onboarding queue, with onboard.py faked: one at a time, Stop skips the
281
- # rest, each outcome read from its exit and log, Clear forgets a finished batch.
282
- import threading
283
- import time
284
- hold = threading.Event()
285
- runs = tmp / "runs"
286
- runs.mkdir(exist_ok=True)
287
- outcome = {"a": (0, " [1/5] served\n [3/5] synced\n"), "ok": (0, ""),
288
- "quiet": (0, " STOPPING: it answered nothing.\n"),
289
- "bad": (1, "bad is not served.\n")}
290
- order = []
291
-
292
- class Proc:
293
- def __init__(self, served):
294
- self.served = served
295
-
296
- def wait(self):
297
- if self.served == "a":
298
- hold.wait(10)
299
- return outcome[self.served][0]
300
-
301
- def fake_spawn(served, endpoint="", who="ui"):
302
- order.append(served)
303
- (runs / f"onboard-{served}.log").write_text(f"onboarding {served} from e -> alias {served}-x\n"
304
- + outcome[served][1], encoding="utf-8")
305
- return {"ok": True, "errors": [], "log": f"onboard-{served}.log"}, Proc(served)
306
-
307
- def settle():
308
- for _ in range(200):
309
- if not serve.onboard_queue()["running"]:
310
- return
311
- time.sleep(0.05)
312
-
313
- kept = (serve._spawn_onboard, serve._onboard_refusal, serve.DATA)
314
- serve._spawn_onboard, serve._onboard_refusal, serve.DATA = fake_spawn, lambda s, e: [], tmp
315
- try:
316
- first = serve.queue_onboard([{"served": "a", "endpoint": "e"}, {"served": "ok", "endpoint": "e"}])
317
- twice = serve.queue_onboard([{"served": "ok", "endpoint": "e"}])["ok"]
318
- for _ in range(200): # Stop once `a` is under way, as a person would
319
- if order:
320
- break
321
- time.sleep(0.05)
322
- stop = serve.stop_onboard_queue()
323
- stopping = stop["stopping"] and [i["state"] for i in stop["items"]] == ["running", "skipped"]
324
- busy_clear = serve.clear_onboard_queue()["ok"]
325
- after_stop = serve.queue_onboard([{"served": "quiet", "endpoint": "e"}])["ok"] # picked after Stop: runs
326
- step = None # the running model's step, from its log
327
- for _ in range(200):
328
- run = [i for i in serve.onboard_queue()["items"] if i["state"] == "running"]
329
- if run and run[0].get("step"):
330
- step = run[0]["step"]
331
- break
332
- time.sleep(0.05)
333
- hold.set()
334
- settle()
335
- stopped = {i["served"]: i["state"] for i in serve.onboard_queue()["items"]}
336
- order.remove("quiet")
337
- serve.queue_onboard([{"served": s, "endpoint": "e"} for s in ("ok", "quiet", "bad")])
338
- settle()
339
- batch = {i["served"]: (i["state"], i["alias"], bool(i["note"])) for i in serve.onboard_queue()["items"]}
340
- cleared = serve.clear_onboard_queue()["items"]
341
- bad_input = (serve.queue_onboard([])["ok"], serve.queue_onboard("ok")["ok"],
342
- serve.stop_onboard_queue()["ok"])
343
- finally:
344
- hold.set()
345
- serve._spawn_onboard, serve._onboard_refusal, serve.DATA = kept
346
- ok = (first["ok"] and not twice and stopping and not busy_clear and after_stop and step == 3
347
- and stopped == {"a": "done", "ok": "skipped", "quiet": "no answer"} and order == ["a", "ok", "quiet", "bad"]
348
- and batch == {"ok": ("done", "ok-x", False), "quiet": ("no answer", "quiet-x", True),
349
- "bad": ("failed", "bad-x", True)}
350
- and cleared == [] and bad_input == (False, False, False))
351
- print(("ok " if ok else "FAIL ") + "onboarding queue" + ("" if ok else f" -> {stopped} {order} {batch}"))
352
- failures += 0 if ok else 1
353
-
354
- # Dismiss: one picker key or many in one write, a change-log line each;
355
- # undo brings back only the ones named; nothing named is refused.
356
- serve.DISMISSED_PATH = cfg / "dismissed.json"
357
- seen = len(serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines())
358
- many, one = serve.dismiss(["vendor/a", "vendor/b@openrouter"]), serve.dismiss("vendor/c")
359
- gone = set(json.loads(serve.DISMISSED_PATH.read_text(encoding="utf-8")))
360
- back = serve.dismiss(["vendor/a", "vendor/c"], undo=True)
361
- left = set(json.loads(serve.DISMISSED_PATH.read_text(encoding="utf-8")))
362
- log = [(e["field"], e["new"]) for e in map(json.loads, serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines()[seen:])]
363
- refused = tuple(serve.dismiss(x)["ok"] for x in ([], " ", None, [3]))
364
- ok = (many["ok"] and one["ok"] and back["ok"] and gone == {"vendor/a", "vendor/b@openrouter", "vendor/c"}
365
- and left == {"vendor/b@openrouter"} and refused == (False, False, False, False)
366
- and log == [("dismissed", "vendor/a"), ("dismissed", "vendor/b@openrouter"), ("dismissed", "vendor/c"),
367
- ("undismissed", "vendor/a"), ("undismissed", "vendor/c")])
368
- print(("ok " if ok else "FAIL ") + "dismiss in bulk, and undo" + ("" if ok else f" -> {gone} {left} {log} {refused}"))
369
- failures += 0 if ok else 1
370
-
371
- # The owner's files are as they were. alerts.json can change under a
372
- # running suite, so only what this test could do counts: the victim's
373
- # alerts cleared from the real file.
374
- real_alerts = DATA / "metrics" / "alerts.json"
375
- victim_keys = {k for k in json.loads(real[real_alerts] or b"{}") if k.endswith(f":{served}")}
376
- ok = (real[DATA / "config" / "endpoints.json"] == (DATA / "config" / "endpoints.json").read_bytes()
377
- and victim_keys <= set(json.loads(real_alerts.read_bytes() or b"{}")))
378
- print(("ok " if ok else "FAIL ") + "the real alerts and endpoints were not touched")
379
- failures += 0 if ok else 1
380
- shutil.rmtree(tmp, ignore_errors=True)
381
- print("\nall rules hold" if not failures else f"\n{failures} FAILED")
382
- return 1 if failures else 0
383
-
384
-
385
- if __name__ == "__main__":
386
- sys.exit(main())
1
+ """Exercise serve.change_model against a COPY of the config, never the real one.
2
+
3
+ python scripts/test_serve_edit.py
4
+
5
+ Each rule the popup enforces is tried once and must refuse; then a role change
6
+ and a rename are applied to the copy and checked. The llm sync is not run:
7
+ serve.ROOT points at the copy, which has no scripts/sync_config.py, so the
8
+ sync reports that it could not run. Alerts and endpoints point at copies too,
9
+ because removing a model clears its alerts and a regressed endpoint check
10
+ would write the endpoint file.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import pathlib
18
+ import shutil
19
+ import sys
20
+ import tempfile
21
+
22
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
23
+ sys.path.insert(0, str(ROOT / "metrics"))
24
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
25
+ sys.path.insert(0, str(ROOT))
26
+ import serve # noqa: E402
27
+
28
+
29
+ def main() -> int:
30
+ tmp = pathlib.Path(tempfile.mkdtemp(prefix="bench-edit-"))
31
+ cfg = tmp / "config"
32
+ cfg.mkdir()
33
+ for name in ("model-roles.json", "extra-openai-models.yaml",
34
+ "alias-history.json", "model-options.json", "provider-tags.json"):
35
+ shutil.copy(DATA / "config" / name, cfg / name)
36
+ (tmp / "scripts").mkdir()
37
+ (tmp / "metrics").mkdir()
38
+ shutil.copy(ROOT / "metrics" / "aliases.py", tmp / "metrics" / "aliases.py")
39
+ # point serve at the copy
40
+ serve.ROOT = tmp
41
+ serve.DATA = tmp # the data paths serve builds inline follow this one
42
+ serve.ROLES_PATH = cfg / "model-roles.json"
43
+ serve.YAML_PATH = cfg / "extra-openai-models.yaml"
44
+ serve.HISTORY_PATH = cfg / "alias-history.json"
45
+ serve.OPTIONS_PATH = cfg / "model-options.json"
46
+ serve.CHANGES_PATH = cfg / "changes.jsonl"
47
+ serve.RETIRED_PATH = cfg / "retired.json"
48
+ import aliases
49
+ aliases.HISTORY = cfg / "alias-history.json"
50
+ import alerts
51
+ shutil.copy(DATA / "metrics" / "alerts.json", tmp / "metrics" / "alerts.json")
52
+ alerts.FILE = tmp / "metrics" / "alerts.json"
53
+ # Decision models too: a rename or removal of one writes decide.FILE, and
54
+ # which model the rename picks depends on the data (sorted first).
55
+ import decide
56
+ if (DATA / "config" / "decision-models.json").exists():
57
+ shutil.copy(DATA / "config" / "decision-models.json", cfg / "decision-models.json")
58
+ decide.FILE = cfg / "decision-models.json"
59
+ shutil.copy(DATA / "config" / "endpoints.json", cfg / "endpoints.json")
60
+ serve.endpoints.FILE = cfg / "endpoints.json"
61
+ serve.endpoints.YAML = serve.YAML_PATH
62
+ # Collections: removing a model takes it out of them, and Restore puts it back.
63
+ serve.bench_collections.FILE = cfg / "collections.json"
64
+ serve.bench_collections.SETTINGS_FILE = cfg / "gateway.json"
65
+ real = {p: p.read_bytes() for p in (DATA / "metrics" / "alerts.json", DATA / "config" / "endpoints.json")}
66
+ real_collections = (DATA / "config" / "collections.json").read_bytes() \
67
+ if (DATA / "config" / "collections.json").exists() else None
68
+
69
+ d = serve.model_directory()
70
+ some = sorted(d)[0]
71
+ retired = next(iter(json.loads((cfg / "alias-history.json").read_text(encoding="utf-8"))))
72
+ other = next(a for a in d if a != some)
73
+ failures = 0
74
+
75
+ def expect_refused(label, **kw):
76
+ nonlocal failures
77
+ r = serve.change_model(**kw)
78
+ ok = (not r["ok"]) and r["errors"] and not r["changed"]
79
+ print(("ok " if ok else "FAIL ") + label + ("" if ok else f" -> {r}"))
80
+ failures += 0 if ok else 1
81
+
82
+ expect_refused("unknown alias", alias="no-such-model", new_alias="x", new_role="general")
83
+ expect_refused("nothing to change", alias=some, new_alias=some, new_role=d[some]["role"])
84
+ expect_refused("unknown role", alias=some, new_alias=some, new_role="wizard")
85
+ expect_refused("alias with a space", alias=some, new_alias="bad name", new_role=d[some]["role"])
86
+ expect_refused("alias taken", alias=some, new_alias=other, new_role=d[some]["role"])
87
+ expect_refused("retired alias reused", alias=some, new_alias=retired, new_role=d[some]["role"])
88
+ expect_refused("case-only collision", alias=some, new_alias=other.upper(), new_role=d[some]["role"])
89
+
90
+ # role change
91
+ new_role = "code" if d[some]["role"] != "code" else "general"
92
+ r = serve.change_model(some, some, new_role)
93
+ ok = r["ok"] and serve.model_directory()[some]["role"] == new_role
94
+ print(("ok " if ok else "FAIL ") + f"role change {some}: {d[some]['role']} -> {new_role}")
95
+ failures += 0 if ok else 1
96
+
97
+ # ad hoc: a flag, not a role — setting it leaves the role alone
98
+ expect_refused("ad_hoc not a bool", alias=some, new_alias=some, new_role=None,
99
+ new_ad_hoc="yes")
100
+ r = serve.change_model(some, some, None, new_ad_hoc=True)
101
+ ok = (r["ok"] and serve.model_directory()[some]["ad_hoc"] is True
102
+ and serve.model_directory()[some]["role"] == new_role)
103
+ print(("ok " if ok else "FAIL ") + f"ad hoc on {some}, role kept")
104
+ failures += 0 if ok else 1
105
+ expect_refused("ad hoc already set", alias=some, new_alias=some, new_role=None,
106
+ new_ad_hoc=True)
107
+
108
+ # rename
109
+ r = serve.change_model(some, some + "-renamed", None)
110
+ d2 = serve.model_directory()
111
+ hist = json.loads((cfg / "alias-history.json").read_text(encoding="utf-8"))
112
+ roles = json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))["models"]
113
+ held = json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))["ad_hoc"]
114
+ ok = (r["ok"] and some not in d2 and some + "-renamed" in d2
115
+ and some + "-renamed" in held and some not in held
116
+ and d2[some + "-renamed"]["served"] == d[some]["served"]
117
+ and hist.get(some) == some + "-renamed"
118
+ and roles.get(some + "-renamed") == new_role and some not in roles
119
+ and aliases.current(some) == some + "-renamed")
120
+ print(("ok " if ok else "FAIL ") + f"rename {some} -> {some}-renamed; sync: {r['sync'][:60]}")
121
+ failures += 0 if ok else 1
122
+ # and the old name can never come back
123
+ expect_refused("old name refused after rename", alias=some + "-renamed",
124
+ new_alias=some, new_role=None)
125
+ lines = (cfg / "changes.jsonl").read_text(encoding="utf-8").splitlines()
126
+ print(("ok " if len(lines) == 3 else "FAIL ") + f"{len(lines)} changes logged")
127
+ failures += 0 if len(lines) == 3 else 1
128
+
129
+ # removal
130
+ victim = some + "-renamed"
131
+ served = d2[victim]["served"]
132
+ r = serve.remove_model("no-such", "whatever it is")
133
+ ok = not r["ok"] and not r["changed"]
134
+ print(("ok " if ok else "FAIL ") + "remove: unknown alias"); failures += 0 if ok else 1
135
+ # In three collections: a member of two, the merger of one; a fourth without it.
136
+ colls = {"with-it": {"members": [other, victim], "merger": victim, "reply": "merged"},
137
+ "member": {"members": [victim, other], "merger": ""},
138
+ "doomed": {"members": [victim], "merger": ""},
139
+ "without": {"members": [other], "merger": other}}
140
+ (cfg / "collections.json").write_text(json.dumps(colls), encoding="utf-8")
141
+ logged = len(serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines())
142
+ before = len(serve.registered())
143
+ r = serve.remove_model(victim, "retired by provider on 2026-09-15")
144
+ d3 = serve.model_directory()
145
+ retired = json.loads((cfg / "retired.json").read_text(encoding="utf-8"))
146
+ yaml = (cfg / "extra-openai-models.yaml").read_text(encoding="utf-8")
147
+ ok = (r["ok"] and victim not in d3 and len(serve.registered()) == before - 1
148
+ and retired[victim]["served"] == served and f"model_id: {victim}\n" not in yaml
149
+ and victim not in json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))["models"]
150
+ and victim not in json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))["ad_hoc"]
151
+ and served not in serve.registered())
152
+ print(("ok " if ok else "FAIL ") + f"removed {victim}; registry {before} -> {len(serve.registered())}")
153
+ failures += 0 if ok else 1
154
+ after = json.loads((cfg / "collections.json").read_text(encoding="utf-8"))
155
+ log = [json.loads(x) for x in serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines()[logged:]]
156
+ ok = (after == {"with-it": {"members": [other], "merger": "", "reply": "merged"},
157
+ "member": {"members": [other], "merger": ""},
158
+ "doomed": {"members": [], "merger": ""},
159
+ "without": colls["without"]}
160
+ and retired[victim]["collections"] == {"with-it": ["members", "merger"], "member": ["members"],
161
+ "doomed": ["members"]}
162
+ and "config/collections.json" in r["changed"]
163
+ and sorted(e["field"] for e in log if e["field"].startswith("collection "))
164
+ == ["collection doomed", "collection member", "collection with-it"])
165
+ print(("ok " if ok else "FAIL ") + "removed from its collections, recorded and logged"
166
+ + ("" if ok else f" -> {after} {retired[victim]} {r['changed']} {log}")); failures += 0 if ok else 1
167
+ expect_refused("retired name never reused", alias=other, new_alias=victim, new_role=None)
168
+ # The collection dialog's member search matches the served name too, so the view carries it.
169
+ view = serve.collections_view()
170
+ ok = (set(view["served"]) == set(view["models"]) and victim not in view["served"]
171
+ and view["served"].get(other) == d3[other]["served"])
172
+ print(("ok " if ok else "FAIL ") + "collection view: served name for every member choice"); failures += 0 if ok else 1
173
+ # restore: same alias back, out of retired.json, role and ad hoc kept, and
174
+ # back into the collections that still exist (one deleted, one changed since)
175
+ serve.delete_collection("doomed")
176
+ serve.bench_collections.FILE.write_text(json.dumps({**json.loads(serve.bench_collections.FILE.read_text(
177
+ encoding="utf-8")), "member": {"members": [other, "someone-new"], "merger": ""}}), encoding="utf-8")
178
+ r = serve.restore_model(victim)
179
+ retired = json.loads((cfg / "retired.json").read_text(encoding="utf-8"))
180
+ roles = json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))
181
+ ok = (r["ok"] and serve.registered().get(served) == victim and victim not in retired
182
+ and roles["models"].get(victim) == new_role and victim in roles["ad_hoc"])
183
+ print(("ok " if ok else "FAIL ") + f"restored {victim}" + ("" if ok else f" -> {r}"))
184
+ failures += 0 if ok else 1
185
+ back = json.loads((cfg / "collections.json").read_text(encoding="utf-8"))
186
+ ok = (back == {"with-it": {"members": [other, victim], "merger": victim, "reply": "merged"},
187
+ "member": {"members": [other, "someone-new", victim], "merger": ""},
188
+ "without": colls["without"]}
189
+ and "config/collections.json" in r["changed"])
190
+ print(("ok " if ok else "FAIL ") + "restored into the collections that still exist"
191
+ + ("" if ok else f" -> {back} {r['changed']}")); failures += 0 if ok else 1
192
+ # One that was in no collection: collections.json untouched and not reported.
193
+ (cfg / "collections.json").write_text(json.dumps({"without": colls["without"]}), encoding="utf-8")
194
+ r = serve.remove_model(victim, "") # no reason given: the popup confirmed it
195
+ gone = json.loads((cfg / "retired.json").read_text(encoding="utf-8"))[victim]
196
+ ok = (r["ok"] and "config/collections.json" not in r["changed"]
197
+ and "collections" not in gone and gone["why"] == "retired by hand")
198
+ r2 = serve.restore_model(victim)
199
+ ok = ok and r2["ok"] and "config/collections.json" not in r2["changed"]
200
+ print(("ok " if ok else "FAIL ") + "in no collection: collections.json not touched"
201
+ + ("" if ok else f" -> {r} {r2}")); failures += 0 if ok else 1
202
+ ok = not serve.restore_model(victim)["ok"]
203
+ print(("ok " if ok else "FAIL ") + "restore refused when not retired"); failures += 0 if ok else 1
204
+ serve.remove_model(victim, "retired again for the onboarding checks")
205
+ # onboarding refusals (nothing is started)
206
+ for label, kw in (("onboard: empty", dict(served="")),
207
+ ("onboard: spaces", dict(served="vendor/model x")),
208
+ ("onboard: already registered", dict(served=d3[other]["served"])),
209
+ ("onboard: retired served model", dict(served=served)),
210
+ ("onboard: shell characters", dict(served='m" & calc & "')),
211
+ ("onboard: unknown endpoint", dict(served="vendor/m", endpoint="nope"))):
212
+ r = serve.start_onboard(**kw)
213
+ ok = not r["ok"] and "log" not in r
214
+ print(("ok " if ok else "FAIL ") + label + ("" if ok else f" -> {r}")); failures += 0 if ok else 1
215
+ print(("ok " if serve.onboard_status("../etc/passwd")["ok"] is False else "FAIL ") + "onboard log name guarded")
216
+ # How an onboarding stopped: shown for a registered model until it answers.
217
+ serve.ONBOARD_ISSUES_PATH.parent.mkdir(parents=True, exist_ok=True)
218
+ serve.ONBOARD_ISSUES_PATH.write_text(json.dumps({
219
+ other: {"at": "2026-10-22T16:43:34+02:00", "step": "availability probe", "failure": "capacity",
220
+ "detail": "429 free-models-per-day", "log": ""},
221
+ "gone-model": {"failure": "structural"}}), encoding="utf-8")
222
+ shown = serve.onboard_issues({other: ("throttled", "warn", "")})
223
+ ok = list(shown) == [other] and shown[other]["label"] == "throttled" and shown[other]["css"] == "warn"
224
+ ok = ok and not serve.onboard_issues({other: ("healthy", "ok", "")}) # answered since: gone
225
+ print(("ok " if ok else "FAIL ") + "onboard issues: registered and not answering only"
226
+ + ("" if ok else f" -> {shown}")); failures += 0 if ok else 1
227
+ serve.ONBOARD_ISSUES_PATH.unlink()
228
+ # endpoints: refused before any network call or write
229
+ for label, args in (("endpoint: bad name", ("Bad Name", "https://x.example/v1", "")),
230
+ ("endpoint: bad url", ("good", "ftp://x", "")),
231
+ ("endpoint: default not removable", None)):
232
+ r = serve.save_endpoint(*args) if args else serve.remove_endpoint(serve.endpoints.default())
233
+ ok = not r["ok"]
234
+ print(("ok " if ok else "FAIL ") + label); failures += 0 if ok else 1
235
+
236
+ # Removing an endpoint with a model on it: refused unless asked, then the
237
+ # model retires with it, and Restore will not move it to another endpoint.
238
+ eps = json.loads((cfg / "endpoints.json").read_text(encoding="utf-8"))
239
+ eps["edit-test-ep"] = {"base": "https://edit-test.example/v1", "key": "edit-test-ep"}
240
+ (cfg / "endpoints.json").write_text(json.dumps(eps), encoding="utf-8")
241
+ serve.YAML_PATH.write_text(serve.read(serve.YAML_PATH).rstrip("\n") + "\n"
242
+ + serve.endpoints.yaml_block("edit-test-m", "vendor/edit-test", "edit-test-ep"),
243
+ encoding="utf-8")
244
+ refused = serve.remove_endpoint("edit-test-ep")
245
+ r = serve.remove_endpoint("edit-test-ep", with_models=True)
246
+ back = serve.restore_model("edit-test-m")
247
+ ok = (not refused["ok"] and "1 model" in refused["errors"][0]
248
+ and r["ok"] and r["retired"] == ["edit-test-m"] and "edit-test-ep" not in serve.endpoints.load()
249
+ and "edit-test-m" in json.loads((cfg / "retired.json").read_text(encoding="utf-8"))
250
+ and "edit-test-m" not in serve.model_directory()
251
+ and not back["ok"] and "was removed" in back["errors"][0])
252
+ print(("ok " if ok else "FAIL ") + "endpoint removed with its model; restore refused without it"
253
+ + ("" if ok else f" -> {refused} {r} {back}"))
254
+ failures += 0 if ok else 1
255
+
256
+ # Call options: max_tokens set, refused above the model's max output or as
257
+ # a fraction or for no model, then cleared; the llm CLI's copy is stubbed.
258
+ told, real_info = [], serve.catalog.info
259
+ serve._llm_option = lambda alias, name, value: told.append((alias, name, value)) or "stub"
260
+ serve.catalog.info = lambda a: {**real_info(a), "max_output": 50_000}
261
+ chat = next(a for a, m in sorted(serve.model_directory().items()) if m.get("kind") != "decision")
262
+ before = json.loads(serve.OPTIONS_PATH.read_text(encoding="utf-8"))
263
+ try:
264
+ put = serve.save_model_options(chat, 31_337)
265
+ mid = json.loads(serve.OPTIONS_PATH.read_text(encoding="utf-8"))
266
+ refused = [serve.save_model_options(chat, 60_000), serve.save_model_options(chat, 12.5),
267
+ serve.save_model_options("no-such-model", 100), serve.save_model_options(chat, True)]
268
+ gone = serve.save_model_options(chat, None)
269
+ after = json.loads(serve.OPTIONS_PATH.read_text(encoding="utf-8"))
270
+ finally:
271
+ serve.catalog.info = real_info
272
+ ok = (put["ok"] and mid[chat]["max_tokens"] == 31_337 and not any(r["ok"] for r in refused)
273
+ and gone["ok"] and "max_tokens" not in after.get(chat, {}) and after.get("_why") == before.get("_why")
274
+ and told == [(chat, "max_tokens", 31_337), (chat, "max_tokens", None)])
275
+ print(("ok " if ok else "FAIL ") + f"call options for {chat}" + ("" if ok else f" -> {put} {refused} {gone} {told}"))
276
+ failures += 0 if ok else 1
277
+
278
+ # The scheduler: a mode and the jobs turned off, nothing else written.
279
+ import scheduler
280
+ scheduler.CONFIG = cfg / "scheduler.json"
281
+ bad = [serve.save_scheduler("sometimes", {}), serve.save_scheduler("off", {"nope": True})]
282
+ good = serve.save_scheduler("log-only", {"perf": False, "suite": True})
283
+ conf = scheduler.settings()
284
+ ok = (not any(r["ok"] for r in bad) and good["ok"]
285
+ and conf == {"mode": "log-only", "jobs": {"perf": False}} and "perf" not in scheduler.enabled(conf))
286
+ print(("ok " if ok else "FAIL ") + "scheduler mode and jobs" + ("" if ok else f" -> {bad} {good} {conf}"))
287
+ failures += 0 if ok else 1
288
+
289
+ # The model client: chosen here unless BENCH_CLIENT is set; llm only where installed.
290
+ import client as model_client
291
+ model_client.CHOICE = cfg / "client.json"
292
+ env, real_which = os.environ.pop("BENCH_CLIENT", None), shutil.which
293
+ try:
294
+ shutil.which = lambda name: "/usr/bin/" + name # llm installed: the default is llm
295
+ first = model_client.chosen()
296
+ shutil.which = lambda name: None # not installed (npm, a container)
297
+ bare = model_client.chosen()
298
+ shutil.which = real_which
299
+ made = serve.save_client("builtin")
300
+ picked = (model_client.chosen(), model_client.backend())
301
+ os.environ["BENCH_CLIENT"] = "llm"
302
+ pinned = (model_client.chosen(), serve.save_client("builtin")["ok"])
303
+ del os.environ["BENCH_CLIENT"]
304
+ shutil.which = lambda name: None
305
+ no_llm = serve.save_client("llm")["ok"]
306
+ shutil.which = real_which
307
+ unknown = serve.save_client("curl")["ok"]
308
+ finally:
309
+ shutil.which = real_which
310
+ os.environ.pop("BENCH_CLIENT", None)
311
+ if env is not None:
312
+ os.environ["BENCH_CLIENT"] = env
313
+ ok = (first == ("llm", "default") and bare == ("builtin", "default") and made["ok"] and picked == (("builtin", "settings"), "builtin")
314
+ and pinned == (("llm", "environment"), False) and not no_llm and not unknown)
315
+ print(("ok " if ok else "FAIL ") + "model client choice" + ("" if ok else f" -> {first} {made} {picked} {pinned}"))
316
+ failures += 0 if ok else 1
317
+
318
+ # Free only in their own app: only what differs from the built-in is written; bad input refused.
319
+ import sources
320
+ real_conn, endpoints_mod = serve.endpoints.CONNECTIONS, serve.endpoints
321
+ endpoints_mod.CONNECTIONS = cfg / "connections.json"
322
+ try:
323
+ endpoints_mod.CONNECTIONS.write_text('{"paid": ["x"]}', encoding="utf-8")
324
+ off = serve.save_own_client_free({"opencode": False, "opencode-go": True})["ok"]
325
+ wrote = json.loads(endpoints_mod.CONNECTIONS.read_text(encoding="utf-8"))
326
+ seen_off = sources.free_in_own_client_only()["opencode"]
327
+ back = serve.save_own_client_free({"opencode": True, "opencode-go": True})["ok"]
328
+ cleared = json.loads(endpoints_mod.CONNECTIONS.read_text(encoding="utf-8"))
329
+ refused = (serve.save_own_client_free({"no-such-provider-x": True})["ok"],
330
+ serve.save_own_client_free({"opencode": "no"})["ok"], serve.save_own_client_free(["opencode"])["ok"])
331
+ card = serve.own_client_card()
332
+ finally:
333
+ endpoints_mod.CONNECTIONS = real_conn
334
+ ok = (off and wrote == {"paid": ["x"], "free_in_own_client_only": {"opencode": False}} and seen_off is False
335
+ and back and cleared == {"paid": ["x"]} and refused == (False, False, False)
336
+ and "value='opencode'" in card and "oc-save" in card)
337
+ print(("ok " if ok else "FAIL ") + "free only in their own app" + ("" if ok else f" -> {wrote} {cleared} {refused}"))
338
+ failures += 0 if ok else 1
339
+
340
+ # The onboarding queue, with onboard.py faked: one at a time, Stop skips the
341
+ # rest, each outcome read from its exit and log, Clear forgets a finished batch.
342
+ import threading
343
+ import time
344
+ hold = threading.Event()
345
+ runs = tmp / "runs"
346
+ runs.mkdir(exist_ok=True)
347
+ outcome = {"a": (0, " [1/5] served\n [3/5] synced\n"), "ok": (0, ""),
348
+ "quiet": (0, " STOPPING: it answered nothing.\n"),
349
+ "bad": (1, "bad is not served.\n")}
350
+ order = []
351
+
352
+ class Proc:
353
+ def __init__(self, served):
354
+ self.served = served
355
+
356
+ def wait(self):
357
+ if self.served == "a":
358
+ hold.wait(10)
359
+ return outcome[self.served][0]
360
+
361
+ def fake_spawn(served, endpoint="", who="ui"):
362
+ order.append(served)
363
+ (runs / f"onboard-{served}.log").write_text(f"onboarding {served} from e -> alias {served}-x\n"
364
+ + outcome[served][1], encoding="utf-8")
365
+ return {"ok": True, "errors": [], "log": f"onboard-{served}.log"}, Proc(served)
366
+
367
+ def settle():
368
+ for _ in range(200):
369
+ if not serve.onboard_queue()["running"]:
370
+ return
371
+ time.sleep(0.05)
372
+
373
+ kept = (serve._spawn_onboard, serve._onboard_refusal, serve.DATA)
374
+ serve._spawn_onboard, serve._onboard_refusal, serve.DATA = fake_spawn, lambda s, e: [], tmp
375
+ try:
376
+ first = serve.queue_onboard([{"served": "a", "endpoint": "e"}, {"served": "ok", "endpoint": "e"}])
377
+ twice = serve.queue_onboard([{"served": "ok", "endpoint": "e"}])["ok"]
378
+ for _ in range(200): # Stop once `a` is under way, as a person would
379
+ if order:
380
+ break
381
+ time.sleep(0.05)
382
+ stop = serve.stop_onboard_queue()
383
+ stopping = stop["stopping"] and [i["state"] for i in stop["items"]] == ["running", "skipped"]
384
+ busy_clear = serve.clear_onboard_queue()["ok"]
385
+ after_stop = serve.queue_onboard([{"served": "quiet", "endpoint": "e"}])["ok"] # picked after Stop: runs
386
+ step = None # the running model's step, from its log
387
+ for _ in range(200):
388
+ run = [i for i in serve.onboard_queue()["items"] if i["state"] == "running"]
389
+ if run and run[0].get("step"):
390
+ step = run[0]["step"]
391
+ break
392
+ time.sleep(0.05)
393
+ hold.set()
394
+ settle()
395
+ stopped = {i["served"]: i["state"] for i in serve.onboard_queue()["items"]}
396
+ order.remove("quiet")
397
+ serve.queue_onboard([{"served": s, "endpoint": "e"} for s in ("ok", "quiet", "bad")])
398
+ settle()
399
+ batch = {i["served"]: (i["state"], i["alias"], bool(i["note"])) for i in serve.onboard_queue()["items"]}
400
+ cleared = serve.clear_onboard_queue()["items"]
401
+ bad_input = (serve.queue_onboard([])["ok"], serve.queue_onboard("ok")["ok"],
402
+ serve.stop_onboard_queue()["ok"])
403
+ finally:
404
+ hold.set()
405
+ serve._spawn_onboard, serve._onboard_refusal, serve.DATA = kept
406
+ ok = (first["ok"] and not twice and stopping and not busy_clear and after_stop and step == 3
407
+ and stopped == {"a": "done", "ok": "skipped", "quiet": "no answer"} and order == ["a", "ok", "quiet", "bad"]
408
+ and batch == {"ok": ("done", "ok-x", False), "quiet": ("no answer", "quiet-x", True),
409
+ "bad": ("failed", "bad-x", True)}
410
+ and cleared == [] and bad_input == (False, False, False))
411
+ print(("ok " if ok else "FAIL ") + "onboarding queue" + ("" if ok else f" -> {stopped} {order} {batch}"))
412
+ failures += 0 if ok else 1
413
+
414
+ # Dismiss: one picker key or many in one write, a change-log line each;
415
+ # undo brings back only the ones named; nothing named is refused.
416
+ serve.DISMISSED_PATH = cfg / "dismissed.json"
417
+ seen = len(serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines())
418
+ many, one = serve.dismiss(["vendor/a", "vendor/b@openrouter"]), serve.dismiss("vendor/c")
419
+ gone = set(json.loads(serve.DISMISSED_PATH.read_text(encoding="utf-8")))
420
+ back = serve.dismiss(["vendor/a", "vendor/c"], undo=True)
421
+ left = set(json.loads(serve.DISMISSED_PATH.read_text(encoding="utf-8")))
422
+ log = [(e["field"], e["new"]) for e in map(json.loads, serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines()[seen:])]
423
+ refused = tuple(serve.dismiss(x)["ok"] for x in ([], " ", None, [3]))
424
+ ok = (many["ok"] and one["ok"] and back["ok"] and gone == {"vendor/a", "vendor/b@openrouter", "vendor/c"}
425
+ and left == {"vendor/b@openrouter"} and refused == (False, False, False, False)
426
+ and log == [("dismissed", "vendor/a"), ("dismissed", "vendor/b@openrouter"), ("dismissed", "vendor/c"),
427
+ ("undismissed", "vendor/a"), ("undismissed", "vendor/c")])
428
+ print(("ok " if ok else "FAIL ") + "dismiss in bulk, and undo" + ("" if ok else f" -> {gone} {left} {log} {refused}"))
429
+ failures += 0 if ok else 1
430
+
431
+ # The owner's files are as they were. alerts.json can change under a
432
+ # running suite, so only what this test could do counts: the victim's
433
+ # alerts cleared from the real file.
434
+ real_alerts = DATA / "metrics" / "alerts.json"
435
+ victim_keys = {k for k in json.loads(real[real_alerts] or b"{}") if k.endswith(f":{served}")}
436
+ ok = (real[DATA / "config" / "endpoints.json"] == (DATA / "config" / "endpoints.json").read_bytes()
437
+ and victim_keys <= set(json.loads(real_alerts.read_bytes() or b"{}"))
438
+ and real_collections == ((DATA / "config" / "collections.json").read_bytes()
439
+ if (DATA / "config" / "collections.json").exists() else None))
440
+ print(("ok " if ok else "FAIL ") + "the real alerts, endpoints and collections were not touched")
441
+ failures += 0 if ok else 1
442
+ shutil.rmtree(tmp, ignore_errors=True)
443
+ print("\nall rules hold" if not failures else f"\n{failures} FAILED")
444
+ return 1 if failures else 0
445
+
446
+
447
+ if __name__ == "__main__":
448
+ sys.exit(main())