execweave 0.6.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.
Files changed (49) hide show
  1. execweave/__init__.py +3 -0
  2. execweave/__main__.py +5 -0
  3. execweave/analysis.py +406 -0
  4. execweave/backends.py +63 -0
  5. execweave/benchmark.py +80 -0
  6. execweave/claude_adapter.py +448 -0
  7. execweave/claude_hook_cli.py +101 -0
  8. execweave/claude_record.py +106 -0
  9. execweave/cli.py +588 -0
  10. execweave/codex_adapter.py +314 -0
  11. execweave/codex_hook_cli.py +98 -0
  12. execweave/codex_record.py +111 -0
  13. execweave/collector.py +301 -0
  14. execweave/correlation.py +604 -0
  15. execweave/cursor_adapter.py +347 -0
  16. execweave/cursor_hook_cli.py +82 -0
  17. execweave/cursor_record.py +96 -0
  18. execweave/filesystem.py +103 -0
  19. execweave/focus.py +118 -0
  20. execweave/gemini_adapter.py +265 -0
  21. execweave/gemini_hook_cli.py +77 -0
  22. execweave/gemini_record.py +94 -0
  23. execweave/graph.py +300 -0
  24. execweave/graph_ops.py +446 -0
  25. execweave/inference_gateway.py +422 -0
  26. execweave/inference_gateway_cli.py +106 -0
  27. execweave/inference_identity.py +76 -0
  28. execweave/inference_identity_cli.py +60 -0
  29. execweave/live.py +275 -0
  30. execweave/model_runtime.py +535 -0
  31. execweave/model_runtime_cli.py +154 -0
  32. execweave/opencode_adapter.py +316 -0
  33. execweave/opencode_hook_cli.py +57 -0
  34. execweave/opencode_plugin_cli.py +110 -0
  35. execweave/opencode_record.py +96 -0
  36. execweave/overhead_benchmark.py +440 -0
  37. execweave/provider_record.py +215 -0
  38. execweave/schema.py +62 -0
  39. execweave/semantic.py +346 -0
  40. execweave/sink.py +33 -0
  41. execweave/strace_backend.py +682 -0
  42. execweave/validate.py +193 -0
  43. execweave/viewer.py +283 -0
  44. execweave/workflow.py +114 -0
  45. execweave-0.6.0.dist-info/METADATA +356 -0
  46. execweave-0.6.0.dist-info/RECORD +49 -0
  47. execweave-0.6.0.dist-info/WHEEL +4 -0
  48. execweave-0.6.0.dist-info/entry_points.txt +17 -0
  49. execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,535 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import urlsplit, urlunsplit
11
+
12
+
13
+ def _now() -> str:
14
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
15
+
16
+
17
+ def _entity(
18
+ entity_type: str,
19
+ entity_id: str,
20
+ *,
21
+ name: str | None = None,
22
+ attributes: dict[str, Any] | None = None,
23
+ ) -> dict[str, Any]:
24
+ return {"type": entity_type, "id": entity_id, "name": name, "attributes": attributes or {}}
25
+
26
+
27
+ def _event(
28
+ *,
29
+ timestamp: str,
30
+ event_type: str,
31
+ relation: str,
32
+ source: dict[str, Any],
33
+ target: dict[str, Any],
34
+ runtime: str,
35
+ attributes: dict[str, Any] | None = None,
36
+ ) -> dict[str, Any]:
37
+ merged = {
38
+ "backend": "model_runtime",
39
+ "attribution": "provider_api",
40
+ "evidence_source": "model_runtime_api",
41
+ "provider": runtime,
42
+ "causal": False,
43
+ }
44
+ if attributes:
45
+ merged.update(attributes)
46
+ return {
47
+ "timestamp": timestamp,
48
+ "event_type": event_type,
49
+ "relation": relation,
50
+ "source": source,
51
+ "target": target,
52
+ "attributes": merged,
53
+ }
54
+
55
+
56
+ def sanitize_endpoint(endpoint: str) -> str:
57
+ split = urlsplit(endpoint)
58
+ if split.scheme not in {"http", "https"} or not split.hostname:
59
+ raise ValueError("model runtime endpoint must be an http(s) URL")
60
+ host = split.hostname
61
+ if ":" in host and not host.startswith("["):
62
+ host = f"[{host}]"
63
+ if split.port is not None:
64
+ host = f"{host}:{split.port}"
65
+ path = split.path.rstrip("/")
66
+ return urlunsplit((split.scheme, host, path, "", ""))
67
+
68
+
69
+ def _runtime_entity(runtime: str, endpoint: str) -> dict[str, Any]:
70
+ safe_endpoint = sanitize_endpoint(endpoint)
71
+ digest = hashlib.sha256(safe_endpoint.encode("utf-8")).hexdigest()[:24]
72
+ return _entity(
73
+ "model_runtime",
74
+ f"model-runtime:{runtime}:{digest}",
75
+ name=runtime,
76
+ attributes={"provider": runtime, "endpoint": safe_endpoint},
77
+ )
78
+
79
+
80
+ def _looks_like_local_model_path(model: str) -> bool:
81
+ normalized = model.replace("\\", "/")
82
+ if normalized.startswith(("/", "~/")):
83
+ return True
84
+ if len(model) >= 3 and model[1] == ":" and model[2] in {"/", "\\"}:
85
+ return True
86
+ return model.lower().endswith(".gguf") and ("/" in model or "\\" in model)
87
+
88
+
89
+ def _model_entity(runtime: str, model: str) -> dict[str, Any]:
90
+ redact = _looks_like_local_model_path(model)
91
+ if runtime == "llamacpp" and ("/" in model or "\\" in model or model.lower().endswith(".gguf")):
92
+ redact = True
93
+ if redact:
94
+ basename = model.replace("\\", "/").rsplit("/", 1)[-1] or "model"
95
+ digest = hashlib.sha256(model.encode("utf-8", errors="replace")).hexdigest()[:24]
96
+ return _entity(
97
+ "model",
98
+ f"model:{runtime}:redacted:{digest}",
99
+ name=basename,
100
+ attributes={"provider": runtime, "native_model_id_redacted": True},
101
+ )
102
+ return _entity(
103
+ "model",
104
+ f"model:{runtime}:{model}",
105
+ name=model,
106
+ attributes={"provider": runtime},
107
+ )
108
+
109
+
110
+ def _request_id(runtime: str, payload: dict[str, Any], explicit: str | None) -> str:
111
+ if explicit:
112
+ return explicit
113
+ native = payload.get("id")
114
+ if isinstance(native, str) and native:
115
+ return native
116
+ seed = {
117
+ "model": payload.get("model"),
118
+ "created_at": payload.get("created_at"),
119
+ "created": payload.get("created"),
120
+ "done_reason": payload.get("done_reason"),
121
+ "total_duration": payload.get("total_duration"),
122
+ "prompt_eval_count": payload.get("prompt_eval_count"),
123
+ "eval_count": payload.get("eval_count"),
124
+ }
125
+ raw = json.dumps(seed, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
126
+ return hashlib.sha256((runtime + "\0" + raw).encode("utf-8", errors="replace")).hexdigest()[:32]
127
+
128
+
129
+ def _inference_entities(
130
+ runtime: str,
131
+ payload: dict[str, Any],
132
+ *,
133
+ endpoint: str,
134
+ request_id: str | None,
135
+ timestamp: str,
136
+ attributes: dict[str, Any],
137
+ ) -> list[dict[str, Any]]:
138
+ runtime_entity = _runtime_entity(runtime, endpoint)
139
+ request_native_id = _request_id(runtime, payload, request_id)
140
+ request = _entity(
141
+ "inference_request",
142
+ f"inference-request:{runtime}:{request_native_id}",
143
+ name=request_native_id,
144
+ attributes={"provider": runtime, **attributes},
145
+ )
146
+ events = [
147
+ _event(
148
+ timestamp=timestamp,
149
+ event_type=f"model_runtime.{runtime}.inference.observed",
150
+ relation="SERVED_INFERENCE",
151
+ source=runtime_entity,
152
+ target=request,
153
+ runtime=runtime,
154
+ attributes=attributes,
155
+ )
156
+ ]
157
+ model = payload.get("model")
158
+ if isinstance(model, str) and model:
159
+ events.append(
160
+ _event(
161
+ timestamp=timestamp,
162
+ event_type=f"model_runtime.{runtime}.model.used",
163
+ relation="USED_MODEL",
164
+ source=request,
165
+ target=_model_entity(runtime, model),
166
+ runtime=runtime,
167
+ attributes=attributes,
168
+ )
169
+ )
170
+ return events
171
+
172
+
173
+ def _copy_int(mapping: dict[str, Any], source: str, target: str, attrs: dict[str, Any]) -> None:
174
+ value = mapping.get(source)
175
+ if isinstance(value, int) and not isinstance(value, bool) and target not in attrs:
176
+ attrs[target] = value
177
+
178
+
179
+ def _openai_usage_attributes(payload: dict[str, Any]) -> dict[str, Any]:
180
+ attrs: dict[str, Any] = {"protocol": "openai_compatible"}
181
+ usage = payload.get("usage")
182
+ if not isinstance(usage, dict):
183
+ return attrs
184
+
185
+ for source, target in (
186
+ ("prompt_tokens", "prompt_tokens"),
187
+ ("input_tokens", "prompt_tokens"),
188
+ ("completion_tokens", "completion_tokens"),
189
+ ("output_tokens", "completion_tokens"),
190
+ ("total_tokens", "total_tokens"),
191
+ ):
192
+ _copy_int(usage, source, target, attrs)
193
+
194
+ for detail_key in ("prompt_tokens_details", "input_tokens_details"):
195
+ details = usage.get(detail_key)
196
+ if isinstance(details, dict):
197
+ _copy_int(details, "cached_tokens", "cached_prompt_tokens", attrs)
198
+ for detail_key in ("completion_tokens_details", "output_tokens_details"):
199
+ details = usage.get(detail_key)
200
+ if isinstance(details, dict):
201
+ _copy_int(details, "reasoning_tokens", "reasoning_tokens", attrs)
202
+ return attrs
203
+
204
+
205
+ def openai_compatible_response_to_events(
206
+ payload: dict[str, Any],
207
+ *,
208
+ runtime: str,
209
+ endpoint: str,
210
+ request_id: str | None = None,
211
+ timestamp: str | None = None,
212
+ extra_attributes: dict[str, Any] | None = None,
213
+ ) -> list[dict[str, Any]]:
214
+ attrs = _openai_usage_attributes(payload)
215
+ if extra_attributes:
216
+ attrs.update(extra_attributes)
217
+ return _inference_entities(
218
+ runtime,
219
+ payload,
220
+ endpoint=endpoint,
221
+ request_id=request_id,
222
+ timestamp=timestamp or _now(),
223
+ attributes=attrs,
224
+ )
225
+
226
+
227
+ def ollama_response_to_events(
228
+ payload: dict[str, Any],
229
+ *,
230
+ endpoint: str = "http://localhost:11434",
231
+ request_id: str | None = None,
232
+ timestamp: str | None = None,
233
+ ) -> list[dict[str, Any]]:
234
+ if not isinstance(payload.get("model"), str) or not payload.get("model"):
235
+ raise ValueError("Ollama response requires model")
236
+ attrs: dict[str, Any] = {"protocol": "ollama_native"}
237
+ mapping = {
238
+ "done_reason": "finish_reason",
239
+ "total_duration": "total_duration_ns",
240
+ "load_duration": "load_duration_ns",
241
+ "prompt_eval_count": "prompt_tokens",
242
+ "prompt_eval_duration": "prompt_eval_duration_ns",
243
+ "eval_count": "completion_tokens",
244
+ "eval_duration": "completion_duration_ns",
245
+ }
246
+ for source, target in mapping.items():
247
+ value = payload.get(source)
248
+ if isinstance(value, (str, int, float)) and not isinstance(value, bool):
249
+ attrs[target] = value
250
+ return _inference_entities(
251
+ "ollama",
252
+ payload,
253
+ endpoint=endpoint,
254
+ request_id=request_id,
255
+ timestamp=timestamp or _now(),
256
+ attributes=attrs,
257
+ )
258
+
259
+
260
+ def llamacpp_response_to_events(
261
+ payload: dict[str, Any],
262
+ *,
263
+ endpoint: str = "http://localhost:8080",
264
+ request_id: str | None = None,
265
+ timestamp: str | None = None,
266
+ ) -> list[dict[str, Any]]:
267
+ extra: dict[str, Any] = {}
268
+ timings = payload.get("timings")
269
+ if isinstance(timings, dict):
270
+ for key in (
271
+ "cache_n",
272
+ "prompt_n",
273
+ "prompt_ms",
274
+ "prompt_per_token_ms",
275
+ "prompt_per_second",
276
+ "predicted_n",
277
+ "predicted_ms",
278
+ "predicted_per_token_ms",
279
+ "predicted_per_second",
280
+ ):
281
+ value = timings.get(key)
282
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
283
+ extra[f"timing_{key}"] = value
284
+ return openai_compatible_response_to_events(
285
+ payload,
286
+ runtime="llamacpp",
287
+ endpoint=endpoint,
288
+ request_id=request_id,
289
+ timestamp=timestamp,
290
+ extra_attributes=extra,
291
+ )
292
+
293
+
294
+ def vllm_response_to_events(
295
+ payload: dict[str, Any],
296
+ *,
297
+ endpoint: str = "http://localhost:8000",
298
+ request_id: str | None = None,
299
+ timestamp: str | None = None,
300
+ ) -> list[dict[str, Any]]:
301
+ return openai_compatible_response_to_events(
302
+ payload,
303
+ runtime="vllm",
304
+ endpoint=endpoint,
305
+ request_id=request_id,
306
+ timestamp=timestamp,
307
+ )
308
+
309
+
310
+ def lmstudio_response_to_events(
311
+ payload: dict[str, Any],
312
+ *,
313
+ endpoint: str = "http://localhost:1234",
314
+ request_id: str | None = None,
315
+ timestamp: str | None = None,
316
+ ) -> list[dict[str, Any]]:
317
+ return openai_compatible_response_to_events(
318
+ payload,
319
+ runtime="lmstudio",
320
+ endpoint=endpoint,
321
+ request_id=request_id,
322
+ timestamp=timestamp,
323
+ )
324
+
325
+
326
+ def ollama_ps_to_events(
327
+ payload: dict[str, Any],
328
+ *,
329
+ endpoint: str = "http://localhost:11434",
330
+ timestamp: str | None = None,
331
+ ) -> list[dict[str, Any]]:
332
+ models = payload.get("models")
333
+ if not isinstance(models, list):
334
+ raise ValueError("Ollama /api/ps response requires models")
335
+ observed_at = timestamp or _now()
336
+ runtime = _runtime_entity("ollama", endpoint)
337
+ events: list[dict[str, Any]] = []
338
+ for item in models:
339
+ if not isinstance(item, dict):
340
+ continue
341
+ name = item.get("model") or item.get("name")
342
+ if not isinstance(name, str) or not name:
343
+ continue
344
+ attrs: dict[str, Any] = {}
345
+ for key in ("size", "size_vram", "context_length", "expires_at", "digest"):
346
+ value = item.get(key)
347
+ if isinstance(value, (str, int, float)) and not isinstance(value, bool):
348
+ attrs[key] = value
349
+ details = item.get("details")
350
+ if isinstance(details, dict):
351
+ for key in ("format", "family", "parameter_size", "quantization_level"):
352
+ value = details.get(key)
353
+ if isinstance(value, str) and value:
354
+ attrs[key] = value
355
+ events.append(
356
+ _event(
357
+ timestamp=observed_at,
358
+ event_type="model_runtime.ollama.model.loaded",
359
+ relation="LOADED_MODEL",
360
+ source=runtime,
361
+ target=_model_entity("ollama", name),
362
+ runtime="ollama",
363
+ attributes=attrs,
364
+ )
365
+ )
366
+ return events
367
+
368
+
369
+ def openai_compatible_models_to_events(
370
+ payload: dict[str, Any],
371
+ *,
372
+ runtime: str,
373
+ endpoint: str,
374
+ relation: str = "SERVES_MODEL",
375
+ event_suffix: str = "model.served",
376
+ timestamp: str | None = None,
377
+ meta_keys: tuple[str, ...] = (),
378
+ ) -> list[dict[str, Any]]:
379
+ data = payload.get("data")
380
+ if not isinstance(data, list):
381
+ raise ValueError("OpenAI-compatible /v1/models response requires data")
382
+ observed_at = timestamp or _now()
383
+ runtime_entity = _runtime_entity(runtime, endpoint)
384
+ events: list[dict[str, Any]] = []
385
+ for item in data:
386
+ if not isinstance(item, dict):
387
+ continue
388
+ model_id = item.get("id")
389
+ if not isinstance(model_id, str) or not model_id:
390
+ continue
391
+ attrs: dict[str, Any] = {"protocol": "openai_compatible"}
392
+ owned_by = item.get("owned_by")
393
+ if isinstance(owned_by, str) and owned_by:
394
+ attrs["owned_by"] = owned_by
395
+ created = item.get("created")
396
+ if isinstance(created, int) and not isinstance(created, bool):
397
+ attrs["created"] = created
398
+ meta = item.get("meta")
399
+ if isinstance(meta, dict):
400
+ for key in meta_keys:
401
+ value = meta.get(key)
402
+ if isinstance(value, (str, int, float)) and not isinstance(value, bool):
403
+ attrs[key] = value
404
+ events.append(
405
+ _event(
406
+ timestamp=observed_at,
407
+ event_type=f"model_runtime.{runtime}.{event_suffix}",
408
+ relation=relation,
409
+ source=runtime_entity,
410
+ target=_model_entity(runtime, model_id),
411
+ runtime=runtime,
412
+ attributes=attrs,
413
+ )
414
+ )
415
+ return events
416
+
417
+
418
+ def llamacpp_models_to_events(
419
+ payload: dict[str, Any],
420
+ *,
421
+ endpoint: str = "http://localhost:8080",
422
+ timestamp: str | None = None,
423
+ ) -> list[dict[str, Any]]:
424
+ return openai_compatible_models_to_events(
425
+ payload,
426
+ runtime="llamacpp",
427
+ endpoint=endpoint,
428
+ timestamp=timestamp,
429
+ meta_keys=("vocab_type", "n_vocab", "n_ctx_train", "n_embd", "n_params", "size"),
430
+ )
431
+
432
+
433
+ def vllm_models_to_events(
434
+ payload: dict[str, Any],
435
+ *,
436
+ endpoint: str = "http://localhost:8000",
437
+ timestamp: str | None = None,
438
+ ) -> list[dict[str, Any]]:
439
+ return openai_compatible_models_to_events(
440
+ payload,
441
+ runtime="vllm",
442
+ endpoint=endpoint,
443
+ timestamp=timestamp,
444
+ )
445
+
446
+
447
+ def lmstudio_models_to_events(
448
+ payload: dict[str, Any],
449
+ *,
450
+ endpoint: str = "http://localhost:1234",
451
+ timestamp: str | None = None,
452
+ ) -> list[dict[str, Any]]:
453
+ return openai_compatible_models_to_events(
454
+ payload,
455
+ runtime="lmstudio",
456
+ endpoint=endpoint,
457
+ relation="ADVERTISES_MODEL",
458
+ event_suffix="model.advertised",
459
+ timestamp=timestamp,
460
+ )
461
+
462
+
463
+ def llamacpp_metrics_to_events(
464
+ metrics_text: str,
465
+ *,
466
+ endpoint: str = "http://localhost:8080",
467
+ timestamp: str | None = None,
468
+ ) -> list[dict[str, Any]]:
469
+ values: dict[str, float] = {}
470
+ for raw_line in metrics_text.splitlines():
471
+ line = raw_line.strip()
472
+ if not line or line.startswith("#") or "{" in line:
473
+ continue
474
+ parts = line.split()
475
+ if len(parts) != 2 or not parts[0].startswith("llamacpp:"):
476
+ continue
477
+ try:
478
+ values[parts[0]] = float(parts[1])
479
+ except ValueError:
480
+ continue
481
+ if not values:
482
+ return []
483
+ observed_at = timestamp or _now()
484
+ runtime = _runtime_entity("llamacpp", endpoint)
485
+ raw = json.dumps(values, sort_keys=True, separators=(",", ":"))
486
+ digest = hashlib.sha256((observed_at + "\0" + raw).encode("utf-8")).hexdigest()[:24]
487
+ snapshot = _entity(
488
+ "model_runtime_snapshot",
489
+ f"model-runtime-snapshot:llamacpp:{digest}",
490
+ name="llama.cpp metrics",
491
+ attributes={"provider": "llamacpp", "metrics": values},
492
+ )
493
+ return [
494
+ _event(
495
+ timestamp=observed_at,
496
+ event_type="model_runtime.llamacpp.metrics.observed",
497
+ relation="REPORTED_METRICS",
498
+ source=runtime,
499
+ target=snapshot,
500
+ runtime="llamacpp",
501
+ attributes={"metric_count": len(values)},
502
+ )
503
+ ]
504
+
505
+
506
+ def append_model_runtime_records(path: str | Path, records: list[dict[str, Any]]) -> Path:
507
+ output = Path(path).expanduser().resolve()
508
+ output.parent.mkdir(parents=True, exist_ok=True)
509
+ if not records:
510
+ return output
511
+ blob = "".join(
512
+ json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
513
+ for record in records
514
+ )
515
+ lock_dir = output.with_name(output.name + ".lock")
516
+ deadline = time.monotonic() + 5.0
517
+ while True:
518
+ try:
519
+ lock_dir.mkdir()
520
+ break
521
+ except FileExistsError:
522
+ if time.monotonic() >= deadline:
523
+ raise TimeoutError(f"timed out waiting for model runtime sidecar lock: {lock_dir}")
524
+ time.sleep(0.01)
525
+ try:
526
+ with output.open("a", encoding="utf-8", newline="\n") as handle:
527
+ handle.write(blob)
528
+ handle.flush()
529
+ os.fsync(handle.fileno())
530
+ finally:
531
+ try:
532
+ lock_dir.rmdir()
533
+ except OSError:
534
+ pass
535
+ return output
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from urllib.error import URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ from .model_runtime import (
12
+ append_model_runtime_records,
13
+ llamacpp_metrics_to_events,
14
+ llamacpp_models_to_events,
15
+ llamacpp_response_to_events,
16
+ lmstudio_models_to_events,
17
+ lmstudio_response_to_events,
18
+ ollama_ps_to_events,
19
+ ollama_response_to_events,
20
+ sanitize_endpoint,
21
+ vllm_models_to_events,
22
+ vllm_response_to_events,
23
+ )
24
+
25
+ _RUNTIMES = ("ollama", "llamacpp", "vllm", "lmstudio")
26
+
27
+
28
+ def _sidecar(value: Path | None) -> Path:
29
+ if value is not None:
30
+ return value
31
+ configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
32
+ if configured:
33
+ return Path(configured)
34
+ raise ValueError("--sidecar or EXECWEAVE_SEMANTIC_SIDECAR is required")
35
+
36
+
37
+ def _read_json_stdin() -> dict:
38
+ raw = sys.stdin.read()
39
+ if not raw.strip():
40
+ raise ValueError("stdin is empty")
41
+ try:
42
+ payload = json.loads(raw)
43
+ except json.JSONDecodeError as exc:
44
+ raise ValueError(f"stdin is invalid JSON: {exc.msg}") from exc
45
+ if not isinstance(payload, dict):
46
+ raise ValueError("stdin must contain one JSON object")
47
+ return payload
48
+
49
+
50
+ def _get_json(url: str, timeout: float) -> dict:
51
+ request = Request(url, headers={"Accept": "application/json"})
52
+ with urlopen(request, timeout=timeout) as response:
53
+ payload = json.loads(response.read().decode("utf-8"))
54
+ if not isinstance(payload, dict):
55
+ raise ValueError(f"{url} did not return a JSON object")
56
+ return payload
57
+
58
+
59
+ def _get_text(url: str, timeout: float) -> str:
60
+ request = Request(url, headers={"Accept": "text/plain"})
61
+ with urlopen(request, timeout=timeout) as response:
62
+ return response.read().decode("utf-8", errors="replace")
63
+
64
+
65
+ def build_parser() -> argparse.ArgumentParser:
66
+ parser = argparse.ArgumentParser(
67
+ prog="execweave-model-runtime",
68
+ description="Capture local model-runtime metadata without prompt or response content.",
69
+ )
70
+ sub = parser.add_subparsers(dest="command", required=True)
71
+
72
+ event = sub.add_parser("event", help="Convert one final provider response into inference metadata events.")
73
+ event.add_argument("--runtime", choices=_RUNTIMES, required=True)
74
+ event.add_argument("--endpoint", default=None)
75
+ event.add_argument("--request-id", default=None)
76
+ event.add_argument("--sidecar", type=Path, default=None)
77
+
78
+ probe = sub.add_parser("probe", help="Snapshot model-runtime catalog and optional aggregate metrics.")
79
+ probe.add_argument("--runtime", choices=_RUNTIMES, required=True)
80
+ probe.add_argument("--endpoint", default=None)
81
+ probe.add_argument("--sidecar", type=Path, default=None)
82
+ probe.add_argument(
83
+ "--metrics",
84
+ action="store_true",
85
+ help="Also collect llama.cpp /metrics when enabled server-side.",
86
+ )
87
+ probe.add_argument("--timeout", type=float, default=3.0)
88
+ return parser
89
+
90
+
91
+ def _default_endpoint(runtime: str) -> str:
92
+ return {
93
+ "ollama": "http://localhost:11434",
94
+ "llamacpp": "http://localhost:8080",
95
+ "vllm": "http://localhost:8000",
96
+ "lmstudio": "http://localhost:1234",
97
+ }[runtime]
98
+
99
+
100
+ def _event_command(args: argparse.Namespace) -> int:
101
+ payload = _read_json_stdin()
102
+ endpoint = sanitize_endpoint(args.endpoint or _default_endpoint(args.runtime))
103
+ converters = {
104
+ "ollama": ollama_response_to_events,
105
+ "llamacpp": llamacpp_response_to_events,
106
+ "vllm": vllm_response_to_events,
107
+ "lmstudio": lmstudio_response_to_events,
108
+ }
109
+ records = converters[args.runtime](
110
+ payload,
111
+ endpoint=endpoint,
112
+ request_id=args.request_id,
113
+ )
114
+ output = append_model_runtime_records(_sidecar(args.sidecar), records)
115
+ print(json.dumps({"records": len(records), "sidecar": str(output)}, sort_keys=True))
116
+ return 0
117
+
118
+
119
+ def _probe_command(args: argparse.Namespace) -> int:
120
+ endpoint = sanitize_endpoint(args.endpoint or _default_endpoint(args.runtime))
121
+ records = []
122
+ if args.runtime == "ollama":
123
+ payload = _get_json(f"{endpoint}/api/ps", args.timeout)
124
+ records.extend(ollama_ps_to_events(payload, endpoint=endpoint))
125
+ else:
126
+ payload = _get_json(f"{endpoint}/v1/models", args.timeout)
127
+ converters = {
128
+ "llamacpp": llamacpp_models_to_events,
129
+ "vllm": vllm_models_to_events,
130
+ "lmstudio": lmstudio_models_to_events,
131
+ }
132
+ records.extend(converters[args.runtime](payload, endpoint=endpoint))
133
+ if args.runtime == "llamacpp" and args.metrics:
134
+ metrics = _get_text(f"{endpoint}/metrics", args.timeout)
135
+ records.extend(llamacpp_metrics_to_events(metrics, endpoint=endpoint))
136
+ output = append_model_runtime_records(_sidecar(args.sidecar), records)
137
+ print(json.dumps({"records": len(records), "sidecar": str(output)}, sort_keys=True))
138
+ return 0
139
+
140
+
141
+ def main(argv: list[str] | None = None) -> int:
142
+ parser = build_parser()
143
+ args = parser.parse_args(argv)
144
+ try:
145
+ if args.command == "event":
146
+ return _event_command(args)
147
+ return _probe_command(args)
148
+ except (OSError, URLError, TimeoutError, ValueError) as exc:
149
+ parser.error(str(exc))
150
+ return 2
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())