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.
- execweave/__init__.py +3 -0
- execweave/__main__.py +5 -0
- execweave/analysis.py +406 -0
- execweave/backends.py +63 -0
- execweave/benchmark.py +80 -0
- execweave/claude_adapter.py +448 -0
- execweave/claude_hook_cli.py +101 -0
- execweave/claude_record.py +106 -0
- execweave/cli.py +588 -0
- execweave/codex_adapter.py +314 -0
- execweave/codex_hook_cli.py +98 -0
- execweave/codex_record.py +111 -0
- execweave/collector.py +301 -0
- execweave/correlation.py +604 -0
- execweave/cursor_adapter.py +347 -0
- execweave/cursor_hook_cli.py +82 -0
- execweave/cursor_record.py +96 -0
- execweave/filesystem.py +103 -0
- execweave/focus.py +118 -0
- execweave/gemini_adapter.py +265 -0
- execweave/gemini_hook_cli.py +77 -0
- execweave/gemini_record.py +94 -0
- execweave/graph.py +300 -0
- execweave/graph_ops.py +446 -0
- execweave/inference_gateway.py +422 -0
- execweave/inference_gateway_cli.py +106 -0
- execweave/inference_identity.py +76 -0
- execweave/inference_identity_cli.py +60 -0
- execweave/live.py +275 -0
- execweave/model_runtime.py +535 -0
- execweave/model_runtime_cli.py +154 -0
- execweave/opencode_adapter.py +316 -0
- execweave/opencode_hook_cli.py +57 -0
- execweave/opencode_plugin_cli.py +110 -0
- execweave/opencode_record.py +96 -0
- execweave/overhead_benchmark.py +440 -0
- execweave/provider_record.py +215 -0
- execweave/schema.py +62 -0
- execweave/semantic.py +346 -0
- execweave/sink.py +33 -0
- execweave/strace_backend.py +682 -0
- execweave/validate.py +193 -0
- execweave/viewer.py +283 -0
- execweave/workflow.py +114 -0
- execweave-0.6.0.dist-info/METADATA +356 -0
- execweave-0.6.0.dist-info/RECORD +49 -0
- execweave-0.6.0.dist-info/WHEEL +4 -0
- execweave-0.6.0.dist-info/entry_points.txt +17 -0
- 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())
|