omnilane 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -1
- package/README.ja.md +8 -0
- package/README.ko.md +8 -0
- package/README.md +38 -2
- package/README.zh-CN.md +8 -0
- package/README.zh-TW.md +29 -2
- package/VERSION +1 -1
- package/benchmarks/workloads.tsv +4 -0
- package/bin/omnilane +10 -2
- package/bin/omnilane-mcp +126 -0
- package/completions/_omnilane +21 -4
- package/completions/omnilane.bash +19 -4
- package/completions/omnilane.fish +15 -3
- package/package.json +2 -1
- package/scripts/benchmark.py +446 -0
- package/scripts/check.sh +1 -1
- package/scripts/doctor.sh +53 -10
- package/scripts/jobs.sh +162 -2
- package/scripts/provider-probe.sh +180 -0
- package/ui/app.js +56 -0
- package/ui/index.html +1 -0
- package/ui/styles.css +22 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fixed, body-free routing quality/cost benchmark."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from decimal import Decimal, InvalidOperation
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
DEFAULT_WORKLOADS = ROOT / "benchmarks" / "workloads.tsv"
|
|
19
|
+
NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
20
|
+
ID_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BenchmarkError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_args(argv=None):
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="omnilane benchmark",
|
|
30
|
+
description=(
|
|
31
|
+
"Compare fixed-workload routing quality and user-supplied per-call "
|
|
32
|
+
"costs. Provider calls require --run."
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--json", action="store_true", help="emit JSON")
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--run",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="invoke providers in advise mode; default is routing-only dry-run",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--vendor",
|
|
43
|
+
action="append",
|
|
44
|
+
default=[],
|
|
45
|
+
metavar="VENDOR",
|
|
46
|
+
help="vendor to compare; repeatable (default: configured vendors)",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--timeout",
|
|
50
|
+
type=int,
|
|
51
|
+
default=60,
|
|
52
|
+
metavar="SECONDS",
|
|
53
|
+
help="per-workload timeout, 1..600 (default: 60)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--workloads",
|
|
57
|
+
type=Path,
|
|
58
|
+
default=DEFAULT_WORKLOADS,
|
|
59
|
+
metavar="FILE",
|
|
60
|
+
help="fixed TSV workload file",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--cost-per-call",
|
|
64
|
+
action="append",
|
|
65
|
+
default=[],
|
|
66
|
+
metavar="VENDOR=USD",
|
|
67
|
+
help="optional user-supplied estimated USD per call; repeatable",
|
|
68
|
+
)
|
|
69
|
+
args = parser.parse_args(argv)
|
|
70
|
+
if not 1 <= args.timeout <= 600:
|
|
71
|
+
parser.error("--timeout must be between 1 and 600 seconds")
|
|
72
|
+
return args
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def unique(values):
|
|
76
|
+
return list(dict.fromkeys(values))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decode_plan_value(value):
|
|
80
|
+
try:
|
|
81
|
+
parsed = shlex.split(value, posix=True)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return value
|
|
84
|
+
return parsed[0] if len(parsed) == 1 else value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_plan(text):
|
|
88
|
+
plan = {}
|
|
89
|
+
for line in text.splitlines():
|
|
90
|
+
if "=" not in line:
|
|
91
|
+
continue
|
|
92
|
+
key, value = line.split("=", 1)
|
|
93
|
+
if re.fullmatch(r"[a-z_]+", key):
|
|
94
|
+
plan[key] = decode_plan_value(value)
|
|
95
|
+
return plan
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_workloads(path):
|
|
99
|
+
try:
|
|
100
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
raise BenchmarkError(f"cannot read workloads: {path}: {exc}") from exc
|
|
103
|
+
|
|
104
|
+
workloads = []
|
|
105
|
+
seen = set()
|
|
106
|
+
for line_number, line in enumerate(lines, 1):
|
|
107
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
108
|
+
continue
|
|
109
|
+
fields = line.split("\t", 4)
|
|
110
|
+
if len(fields) != 5:
|
|
111
|
+
raise BenchmarkError(
|
|
112
|
+
f"{path}:{line_number}: expected 5 tab-separated fields"
|
|
113
|
+
)
|
|
114
|
+
workload_id, lane, weight_text, pattern, prompt = fields
|
|
115
|
+
if not ID_RE.fullmatch(workload_id) or workload_id in seen:
|
|
116
|
+
raise BenchmarkError(f"{path}:{line_number}: invalid or duplicate id")
|
|
117
|
+
if not NAME_RE.fullmatch(lane):
|
|
118
|
+
raise BenchmarkError(f"{path}:{line_number}: invalid lane")
|
|
119
|
+
try:
|
|
120
|
+
weight = int(weight_text)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
raise BenchmarkError(f"{path}:{line_number}: weight must be an integer") from exc
|
|
123
|
+
if not 1 <= weight <= 100:
|
|
124
|
+
raise BenchmarkError(f"{path}:{line_number}: weight must be 1..100")
|
|
125
|
+
try:
|
|
126
|
+
re.compile(pattern)
|
|
127
|
+
except re.error as exc:
|
|
128
|
+
raise BenchmarkError(f"{path}:{line_number}: invalid regex: {exc}") from exc
|
|
129
|
+
if not prompt.strip():
|
|
130
|
+
raise BenchmarkError(f"{path}:{line_number}: prompt must not be empty")
|
|
131
|
+
seen.add(workload_id)
|
|
132
|
+
workloads.append(
|
|
133
|
+
{
|
|
134
|
+
"id": workload_id,
|
|
135
|
+
"lane": lane,
|
|
136
|
+
"weight": weight,
|
|
137
|
+
"pattern": pattern,
|
|
138
|
+
"prompt": prompt,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
if not workloads:
|
|
142
|
+
raise BenchmarkError(f"no workloads found: {path}")
|
|
143
|
+
return workloads
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_costs(items):
|
|
147
|
+
costs = {}
|
|
148
|
+
for item in items:
|
|
149
|
+
if "=" not in item:
|
|
150
|
+
raise BenchmarkError("--cost-per-call must use VENDOR=USD")
|
|
151
|
+
vendor, raw = item.split("=", 1)
|
|
152
|
+
if not NAME_RE.fullmatch(vendor):
|
|
153
|
+
raise BenchmarkError(f"invalid cost vendor: {vendor}")
|
|
154
|
+
try:
|
|
155
|
+
value = Decimal(raw)
|
|
156
|
+
except InvalidOperation as exc:
|
|
157
|
+
raise BenchmarkError(f"invalid cost for {vendor}: {raw}") from exc
|
|
158
|
+
if not value.is_finite() or value < 0:
|
|
159
|
+
raise BenchmarkError(f"cost for {vendor} must be non-negative")
|
|
160
|
+
costs[vendor] = value
|
|
161
|
+
return costs
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def list_routes(dispatch, env):
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
[str(dispatch), "--list"],
|
|
167
|
+
text=True,
|
|
168
|
+
stdout=subprocess.PIPE,
|
|
169
|
+
stderr=subprocess.PIPE,
|
|
170
|
+
env=env,
|
|
171
|
+
timeout=15,
|
|
172
|
+
check=False,
|
|
173
|
+
)
|
|
174
|
+
if result.returncode != 0:
|
|
175
|
+
detail = result.stderr.strip().splitlines()[-1:] or ["unknown error"]
|
|
176
|
+
raise BenchmarkError(f"cannot list routing: {detail[0]}")
|
|
177
|
+
lanes = []
|
|
178
|
+
vendors = []
|
|
179
|
+
for line in result.stdout.splitlines():
|
|
180
|
+
if ":" not in line:
|
|
181
|
+
continue
|
|
182
|
+
lane, raw = line.split(":", 1)
|
|
183
|
+
lane = lane.strip()
|
|
184
|
+
if not NAME_RE.fullmatch(lane):
|
|
185
|
+
continue
|
|
186
|
+
lanes.append(lane)
|
|
187
|
+
try:
|
|
188
|
+
fields = shlex.split(raw.strip())
|
|
189
|
+
except ValueError:
|
|
190
|
+
fields = raw.split()
|
|
191
|
+
if fields and NAME_RE.fullmatch(fields[0]) and fields[0] not in {
|
|
192
|
+
"off",
|
|
193
|
+
"exec",
|
|
194
|
+
"vote",
|
|
195
|
+
}:
|
|
196
|
+
vendors.append(fields[0])
|
|
197
|
+
return unique(lanes), unique(vendors)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def resolve_route(dispatch, vendor, preferred_lane, lanes, prompt, env):
|
|
201
|
+
last_error = "vendor is not configured in an available lane"
|
|
202
|
+
for lane in unique([preferred_lane] + list(lanes)):
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
[
|
|
205
|
+
str(dispatch),
|
|
206
|
+
"--dry-run",
|
|
207
|
+
"--mode",
|
|
208
|
+
"advise",
|
|
209
|
+
"--vendor",
|
|
210
|
+
vendor,
|
|
211
|
+
lane,
|
|
212
|
+
prompt,
|
|
213
|
+
],
|
|
214
|
+
text=True,
|
|
215
|
+
stdout=subprocess.PIPE,
|
|
216
|
+
stderr=subprocess.PIPE,
|
|
217
|
+
env=env,
|
|
218
|
+
timeout=15,
|
|
219
|
+
check=False,
|
|
220
|
+
)
|
|
221
|
+
if result.returncode == 0:
|
|
222
|
+
plan = parse_plan(result.stdout)
|
|
223
|
+
if plan.get("provider_invoked") != "no":
|
|
224
|
+
return None, "dry-run contract did not confirm provider_invoked=no"
|
|
225
|
+
if plan.get("vendor") != vendor:
|
|
226
|
+
return None, "dry-run resolved a different vendor"
|
|
227
|
+
plan.setdefault("lane", lane)
|
|
228
|
+
return plan, ""
|
|
229
|
+
detail = result.stderr.strip().splitlines()
|
|
230
|
+
if detail:
|
|
231
|
+
last_error = detail[-1]
|
|
232
|
+
return None, last_error
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def money(value):
|
|
236
|
+
return format(value.quantize(Decimal("0.01")), "f")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cost_report(vendor, calls, costs):
|
|
240
|
+
if vendor not in costs:
|
|
241
|
+
return None
|
|
242
|
+
per_call = costs[vendor]
|
|
243
|
+
return {
|
|
244
|
+
"basis": "user-supplied-per-call",
|
|
245
|
+
"currency": "USD",
|
|
246
|
+
"per_call_usd": money(per_call),
|
|
247
|
+
"estimated_total_usd": money(per_call * calls),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def run_one(repo, vendor, plan, workload, timeout, env):
|
|
252
|
+
runner = repo / "scripts" / "runners" / f"run-{vendor}.sh"
|
|
253
|
+
base = {
|
|
254
|
+
"id": workload["id"],
|
|
255
|
+
"requested_lane": workload["lane"],
|
|
256
|
+
"resolved_lane": plan.get("lane", workload["lane"]),
|
|
257
|
+
"model": plan.get("model", "-"),
|
|
258
|
+
"effort": plan.get("effort", "-"),
|
|
259
|
+
"weight": workload["weight"],
|
|
260
|
+
}
|
|
261
|
+
if not runner.is_file() or not os.access(runner, os.X_OK):
|
|
262
|
+
return dict(base, status="runner_error", duration_seconds=0.0, response_bytes=0), True
|
|
263
|
+
|
|
264
|
+
started = time.monotonic()
|
|
265
|
+
with tempfile.TemporaryDirectory(prefix="omnilane-benchmark-") as temporary:
|
|
266
|
+
temporary_path = Path(temporary)
|
|
267
|
+
prompt_file = temporary_path / "prompt.txt"
|
|
268
|
+
output_file = temporary_path / "response.txt"
|
|
269
|
+
prompt_file.write_text(str(workload["prompt"]) + "\n", encoding="utf-8")
|
|
270
|
+
run_env = env.copy()
|
|
271
|
+
run_env["OMNILANE_TIMEOUT"] = str(timeout)
|
|
272
|
+
try:
|
|
273
|
+
result = subprocess.run(
|
|
274
|
+
[
|
|
275
|
+
str(runner),
|
|
276
|
+
"advise",
|
|
277
|
+
str(repo),
|
|
278
|
+
plan.get("model", "-"),
|
|
279
|
+
plan.get("effort", "-"),
|
|
280
|
+
str(prompt_file),
|
|
281
|
+
str(output_file),
|
|
282
|
+
],
|
|
283
|
+
stdout=subprocess.DEVNULL,
|
|
284
|
+
stderr=subprocess.DEVNULL,
|
|
285
|
+
env=run_env,
|
|
286
|
+
timeout=timeout + 5,
|
|
287
|
+
check=False,
|
|
288
|
+
)
|
|
289
|
+
except subprocess.TimeoutExpired:
|
|
290
|
+
elapsed = round(time.monotonic() - started, 3)
|
|
291
|
+
return dict(base, status="runner_error", duration_seconds=elapsed, response_bytes=0), True
|
|
292
|
+
elapsed = round(time.monotonic() - started, 3)
|
|
293
|
+
try:
|
|
294
|
+
body = output_file.read_text(encoding="utf-8")
|
|
295
|
+
response_bytes = output_file.stat().st_size
|
|
296
|
+
except OSError:
|
|
297
|
+
body = ""
|
|
298
|
+
response_bytes = 0
|
|
299
|
+
if result.returncode != 0:
|
|
300
|
+
status = "runner_error"
|
|
301
|
+
runner_error = True
|
|
302
|
+
else:
|
|
303
|
+
status = "passed" if re.search(str(workload["pattern"]), body) else "failed"
|
|
304
|
+
runner_error = False
|
|
305
|
+
return dict(
|
|
306
|
+
base,
|
|
307
|
+
status=status,
|
|
308
|
+
duration_seconds=elapsed,
|
|
309
|
+
response_bytes=response_bytes,
|
|
310
|
+
), runner_error
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def execute(args):
|
|
314
|
+
repo = Path(os.environ.get("OMNILANE_BENCHMARK_REPO", str(ROOT))).resolve()
|
|
315
|
+
dispatch = repo / "scripts" / "dispatch.sh"
|
|
316
|
+
if not dispatch.is_file() or not os.access(dispatch, os.X_OK):
|
|
317
|
+
raise BenchmarkError(f"dispatch is not executable: {dispatch}")
|
|
318
|
+
env = os.environ.copy()
|
|
319
|
+
workloads = load_workloads(args.workloads)
|
|
320
|
+
costs = parse_costs(args.cost_per_call)
|
|
321
|
+
lanes, configured_vendors = list_routes(dispatch, env)
|
|
322
|
+
vendors = unique(args.vendor or configured_vendors)
|
|
323
|
+
if not vendors:
|
|
324
|
+
raise BenchmarkError("no configured benchmark vendors found")
|
|
325
|
+
for vendor in vendors:
|
|
326
|
+
if not NAME_RE.fullmatch(vendor) or vendor in {"off", "exec", "vote"}:
|
|
327
|
+
raise BenchmarkError(f"unsupported benchmark vendor: {vendor}")
|
|
328
|
+
|
|
329
|
+
report = {
|
|
330
|
+
"schema_version": 1,
|
|
331
|
+
"command": "benchmark",
|
|
332
|
+
"mode": "run" if args.run else "dry-run",
|
|
333
|
+
"provider_invoked": False,
|
|
334
|
+
"workload_count": len(workloads),
|
|
335
|
+
"workloads_file": str(args.workloads.resolve()),
|
|
336
|
+
"vendors": [],
|
|
337
|
+
}
|
|
338
|
+
had_error = False
|
|
339
|
+
provider_invoked = False
|
|
340
|
+
for vendor in vendors:
|
|
341
|
+
vendor_started = time.monotonic()
|
|
342
|
+
items = []
|
|
343
|
+
passed = 0
|
|
344
|
+
score_possible = 0
|
|
345
|
+
score_earned = 0
|
|
346
|
+
for workload in workloads:
|
|
347
|
+
score_possible += int(workload["weight"])
|
|
348
|
+
plan, error = resolve_route(
|
|
349
|
+
dispatch,
|
|
350
|
+
vendor,
|
|
351
|
+
workload["lane"],
|
|
352
|
+
lanes,
|
|
353
|
+
workload["prompt"],
|
|
354
|
+
env,
|
|
355
|
+
)
|
|
356
|
+
if plan is None:
|
|
357
|
+
items.append(
|
|
358
|
+
{
|
|
359
|
+
"id": workload["id"],
|
|
360
|
+
"requested_lane": workload["lane"],
|
|
361
|
+
"weight": workload["weight"],
|
|
362
|
+
"status": "unavailable",
|
|
363
|
+
"duration_seconds": 0.0,
|
|
364
|
+
"response_bytes": 0,
|
|
365
|
+
"error": error,
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
had_error = True
|
|
369
|
+
continue
|
|
370
|
+
if not args.run:
|
|
371
|
+
items.append(
|
|
372
|
+
{
|
|
373
|
+
"id": workload["id"],
|
|
374
|
+
"requested_lane": workload["lane"],
|
|
375
|
+
"resolved_lane": plan.get("lane", workload["lane"]),
|
|
376
|
+
"model": plan.get("model", "-"),
|
|
377
|
+
"effort": plan.get("effort", "-"),
|
|
378
|
+
"weight": workload["weight"],
|
|
379
|
+
"status": "planned",
|
|
380
|
+
"duration_seconds": 0.0,
|
|
381
|
+
"response_bytes": 0,
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
continue
|
|
385
|
+
provider_invoked = True
|
|
386
|
+
item, runner_error = run_one(
|
|
387
|
+
repo, vendor, plan, workload, args.timeout, env
|
|
388
|
+
)
|
|
389
|
+
items.append(item)
|
|
390
|
+
had_error = had_error or runner_error
|
|
391
|
+
if item["status"] == "passed":
|
|
392
|
+
passed += 1
|
|
393
|
+
score_earned += int(workload["weight"])
|
|
394
|
+
|
|
395
|
+
quality = None
|
|
396
|
+
if args.run and score_possible:
|
|
397
|
+
quality = round((score_earned / score_possible) * 100, 2)
|
|
398
|
+
if quality.is_integer():
|
|
399
|
+
quality = int(quality)
|
|
400
|
+
report["vendors"].append(
|
|
401
|
+
{
|
|
402
|
+
"vendor": vendor,
|
|
403
|
+
"planned_calls": len(workloads),
|
|
404
|
+
"passed": passed,
|
|
405
|
+
"score_earned": score_earned,
|
|
406
|
+
"score_possible": score_possible,
|
|
407
|
+
"quality_percent": quality,
|
|
408
|
+
"duration_seconds": round(time.monotonic() - vendor_started, 3),
|
|
409
|
+
"cost": cost_report(vendor, len(workloads), costs),
|
|
410
|
+
"workloads": items,
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
report["provider_invoked"] = provider_invoked
|
|
414
|
+
return report, 1 if had_error else 0
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def print_human(report):
|
|
418
|
+
invoked = "yes" if report["provider_invoked"] else "no"
|
|
419
|
+
print(f"mode={report['mode']} provider_invoked={invoked}")
|
|
420
|
+
for vendor in report["vendors"]:
|
|
421
|
+
quality = vendor["quality_percent"]
|
|
422
|
+
quality_text = "planned" if quality is None else f"{quality}%"
|
|
423
|
+
cost = vendor["cost"]
|
|
424
|
+
cost_text = "not supplied" if cost is None else f"USD {cost['estimated_total_usd']}"
|
|
425
|
+
print(
|
|
426
|
+
f"{vendor['vendor']}: calls={vendor['planned_calls']} "
|
|
427
|
+
f"quality={quality_text} estimated_cost={cost_text}"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def main(argv=None):
|
|
432
|
+
args = parse_args(argv)
|
|
433
|
+
try:
|
|
434
|
+
report, return_code = execute(args)
|
|
435
|
+
except BenchmarkError as exc:
|
|
436
|
+
print(f"omnilane benchmark: {exc}", file=sys.stderr)
|
|
437
|
+
return 2
|
|
438
|
+
if args.json:
|
|
439
|
+
print(json.dumps(report, ensure_ascii=False, separators=(",", ":")))
|
|
440
|
+
else:
|
|
441
|
+
print_human(report)
|
|
442
|
+
return return_code
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
if __name__ == "__main__":
|
|
446
|
+
raise SystemExit(main())
|
package/scripts/check.sh
CHANGED
|
@@ -75,7 +75,7 @@ fi
|
|
|
75
75
|
|
|
76
76
|
# 4) python compile of the UI and test modules
|
|
77
77
|
py_files=()
|
|
78
|
-
for rel in scripts/ui.py tests/test_ui.py tests/test_ci_policy.py tests/ui_browser_harness.py; do
|
|
78
|
+
for rel in scripts/ui.py scripts/benchmark.py tests/test_ui.py tests/test_benchmark.py tests/test_ci_policy.py tests/ui_browser_harness.py; do
|
|
79
79
|
[[ -f "$REPO/$rel" ]] && py_files+=("$REPO/$rel")
|
|
80
80
|
done
|
|
81
81
|
if ! command -v python3 >/dev/null 2>&1; then
|
package/scripts/doctor.sh
CHANGED
|
@@ -3,14 +3,34 @@ set -u
|
|
|
3
3
|
# Read-only health report for routing, state, watchdog, and optional UI support.
|
|
4
4
|
|
|
5
5
|
JSON_MODE=0
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
STRICT_MODE=0
|
|
7
|
+
PROBE_VENDOR=""
|
|
8
|
+
PROBE_TIMEOUT=30
|
|
9
|
+
PROBE_TIMEOUT_GIVEN=0
|
|
10
|
+
while [[ $# -gt 0 ]]; do
|
|
11
|
+
case "$1" in
|
|
12
|
+
--json) JSON_MODE=1 ;;
|
|
13
|
+
--strict) STRICT_MODE=1 ;;
|
|
14
|
+
--probe)
|
|
15
|
+
[[ $# -ge 2 ]] || { echo "omnilane: --probe needs a vendor" >&2; exit 2; }
|
|
16
|
+
PROBE_VENDOR="$2"; shift ;;
|
|
17
|
+
--probe-timeout)
|
|
18
|
+
[[ $# -ge 2 ]] || { echo "omnilane: --probe-timeout needs seconds" >&2; exit 2; }
|
|
19
|
+
PROBE_TIMEOUT="$2"; PROBE_TIMEOUT_GIVEN=1; shift ;;
|
|
20
|
+
*)
|
|
21
|
+
echo "usage: omnilane doctor [--json] [--strict] [--probe V [--probe-timeout SEC]]" >&2
|
|
22
|
+
exit 2
|
|
23
|
+
;;
|
|
24
|
+
esac
|
|
25
|
+
shift
|
|
26
|
+
done
|
|
27
|
+
[[ "$PROBE_TIMEOUT_GIVEN" -eq 0 || -n "$PROBE_VENDOR" ]] || {
|
|
28
|
+
echo "omnilane: --probe-timeout requires --probe V" >&2
|
|
10
29
|
exit 2
|
|
11
|
-
|
|
30
|
+
}
|
|
12
31
|
REPO="${OMNILANE_DOCTOR_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
13
32
|
OMNILANE_HOME="${OMNILANE_HOME:-$HOME/.omnilane}"
|
|
33
|
+
PROBE_SCRIPT="${OMNILANE_PROVIDER_PROBE_SCRIPT:-$REPO/scripts/provider-probe.sh}"
|
|
14
34
|
PASS_COUNT=0
|
|
15
35
|
WARN_COUNT=0
|
|
16
36
|
FAIL_COUNT=0
|
|
@@ -115,7 +135,10 @@ if [[ -L "$OMNILANE_HOME/jobs" ||
|
|
|
115
135
|
( -e "$OMNILANE_HOME/jobs" && ! -d "$OMNILANE_HOME/jobs" ) ]]; then
|
|
116
136
|
report FAIL job-privacy "$OMNILANE_HOME/jobs must be a real directory, not a symlink or file"
|
|
117
137
|
elif [[ -d "$OMNILANE_HOME/jobs" ]]; then
|
|
118
|
-
jobs_mode="$(stat -f '%Lp' "$OMNILANE_HOME/jobs" 2>/dev/null ||
|
|
138
|
+
jobs_mode="$(stat -f '%Lp' "$OMNILANE_HOME/jobs" 2>/dev/null || true)"
|
|
139
|
+
if [[ ! "$jobs_mode" =~ ^[0-7]{3,4}$ ]]; then
|
|
140
|
+
jobs_mode="$(stat -c '%a' "$OMNILANE_HOME/jobs" 2>/dev/null || true)"
|
|
141
|
+
fi
|
|
119
142
|
if [[ "$jobs_mode" =~ ^[0-7]*00$ ]]; then
|
|
120
143
|
report PASS job-privacy "$OMNILANE_HOME/jobs mode is $jobs_mode"
|
|
121
144
|
elif [[ -n "$jobs_mode" ]]; then
|
|
@@ -178,6 +201,24 @@ else
|
|
|
178
201
|
report WARN vendors "no vendor CLI reachable${vendor_absent:+ (missing: $vendor_absent)}; every lane degrades to off"
|
|
179
202
|
fi
|
|
180
203
|
|
|
204
|
+
# A real inference call is explicit and single-vendor only. Default doctor
|
|
205
|
+
# remains local/offline. Never relay provider output or runner stderr.
|
|
206
|
+
if [[ -n "$PROBE_VENDOR" ]]; then
|
|
207
|
+
if [[ ! -x "$PROBE_SCRIPT" ]]; then
|
|
208
|
+
report FAIL provider-probe "probe runner is unavailable"
|
|
209
|
+
else
|
|
210
|
+
probe_output="$(OMNILANE_PROBE_REPO="$REPO" "$PROBE_SCRIPT" \
|
|
211
|
+
--vendor "$PROBE_VENDOR" --timeout "$PROBE_TIMEOUT" 2>/dev/null)"
|
|
212
|
+
probe_rc=$?
|
|
213
|
+
if [[ "$probe_rc" -eq 0 ]]; then
|
|
214
|
+
report PASS provider-probe "$PROBE_VENDOR bounded live inference succeeded"
|
|
215
|
+
else
|
|
216
|
+
report FAIL provider-probe "$PROBE_VENDOR bounded live inference failed (exit $probe_rc)"
|
|
217
|
+
fi
|
|
218
|
+
: "$probe_output"
|
|
219
|
+
fi
|
|
220
|
+
fi
|
|
221
|
+
|
|
181
222
|
if command -v python3 >/dev/null 2>&1; then
|
|
182
223
|
if python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)' \
|
|
183
224
|
>/dev/null 2>&1; then
|
|
@@ -192,13 +233,15 @@ fi
|
|
|
192
233
|
|
|
193
234
|
if [[ "$JSON_MODE" -eq 1 ]]; then
|
|
194
235
|
ok=true
|
|
195
|
-
[[ "$FAIL_COUNT" -eq 0 ]] || ok=false
|
|
196
|
-
|
|
197
|
-
|
|
236
|
+
[[ "$FAIL_COUNT" -eq 0 && ( "$STRICT_MODE" -eq 0 || "$WARN_COUNT" -eq 0 ) ]] || ok=false
|
|
237
|
+
strict=false
|
|
238
|
+
[[ "$STRICT_MODE" -eq 0 ]] || strict=true
|
|
239
|
+
printf '{"ok":%s,"checks":[%s],"summary":{"passed":%s,"warnings":%s,"failed":%s},"strict":%s}\n' \
|
|
240
|
+
"$ok" "$JSON_REPORTS" "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$strict"
|
|
198
241
|
else
|
|
199
242
|
warning_suffix=s
|
|
200
243
|
[[ "$WARN_COUNT" -eq 1 ]] && warning_suffix=""
|
|
201
244
|
printf '\nSummary: %s passed, %s warning%s, %s failed\n' \
|
|
202
245
|
"$PASS_COUNT" "$WARN_COUNT" "$warning_suffix" "$FAIL_COUNT"
|
|
203
246
|
fi
|
|
204
|
-
[[ "$FAIL_COUNT" -eq 0 ]]
|
|
247
|
+
[[ "$FAIL_COUNT" -eq 0 && ( "$STRICT_MODE" -eq 0 || "$WARN_COUNT" -eq 0 ) ]]
|