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,422 @@
|
|
|
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 sanitize_gateway_endpoint(endpoint: str) -> str:
|
|
18
|
+
split = urlsplit(endpoint)
|
|
19
|
+
if split.scheme not in {"http", "https"} or not split.hostname:
|
|
20
|
+
raise ValueError("inference gateway endpoint must be an http(s) URL")
|
|
21
|
+
host = split.hostname
|
|
22
|
+
if ":" in host and not host.startswith("["):
|
|
23
|
+
host = f"[{host}]"
|
|
24
|
+
if split.port is not None:
|
|
25
|
+
host = f"{host}:{split.port}"
|
|
26
|
+
path = split.path.rstrip("/")
|
|
27
|
+
return urlunsplit((split.scheme, host, path, "", ""))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _entity(
|
|
31
|
+
entity_type: str,
|
|
32
|
+
entity_id: str,
|
|
33
|
+
*,
|
|
34
|
+
name: str | None = None,
|
|
35
|
+
attributes: dict[str, Any] | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
return {"type": entity_type, "id": entity_id, "name": name, "attributes": attributes or {}}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _gateway_entity(gateway: str, endpoint: str) -> dict[str, Any]:
|
|
41
|
+
safe_endpoint = sanitize_gateway_endpoint(endpoint)
|
|
42
|
+
digest = hashlib.sha256(safe_endpoint.encode("utf-8")).hexdigest()[:24]
|
|
43
|
+
return _entity(
|
|
44
|
+
"inference_gateway",
|
|
45
|
+
f"inference-gateway:{gateway}:{digest}",
|
|
46
|
+
name=gateway,
|
|
47
|
+
attributes={"gateway": gateway, "endpoint": safe_endpoint},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _model_entity(model: str) -> dict[str, Any]:
|
|
52
|
+
return _entity(
|
|
53
|
+
"model",
|
|
54
|
+
f"model:catalog:{model}",
|
|
55
|
+
name=model,
|
|
56
|
+
attributes={"catalog_id": model},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _provider_entity(provider: str) -> dict[str, Any]:
|
|
61
|
+
slug = hashlib.sha256(provider.encode("utf-8", errors="replace")).hexdigest()[:24]
|
|
62
|
+
return _entity(
|
|
63
|
+
"inference_provider",
|
|
64
|
+
f"inference-provider:{slug}",
|
|
65
|
+
name=provider,
|
|
66
|
+
attributes={"provider_name": provider},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _deployment_entity(gateway: str, deployment: str) -> dict[str, Any]:
|
|
71
|
+
slug = hashlib.sha256(deployment.encode("utf-8", errors="replace")).hexdigest()[:24]
|
|
72
|
+
return _entity(
|
|
73
|
+
"inference_deployment",
|
|
74
|
+
f"inference-deployment:{gateway}:{slug}",
|
|
75
|
+
name=deployment,
|
|
76
|
+
attributes={"gateway": gateway, "deployment_id": deployment},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _event(
|
|
81
|
+
*,
|
|
82
|
+
timestamp: str,
|
|
83
|
+
event_type: str,
|
|
84
|
+
relation: str,
|
|
85
|
+
source: dict[str, Any],
|
|
86
|
+
target: dict[str, Any],
|
|
87
|
+
gateway: str,
|
|
88
|
+
attributes: dict[str, Any] | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
merged = {
|
|
91
|
+
"backend": "inference_gateway",
|
|
92
|
+
"attribution": "gateway_api",
|
|
93
|
+
"evidence_source": "gateway_response",
|
|
94
|
+
"gateway": gateway,
|
|
95
|
+
"causal": False,
|
|
96
|
+
}
|
|
97
|
+
if attributes:
|
|
98
|
+
merged.update(attributes)
|
|
99
|
+
return {
|
|
100
|
+
"timestamp": timestamp,
|
|
101
|
+
"event_type": event_type,
|
|
102
|
+
"relation": relation,
|
|
103
|
+
"source": source,
|
|
104
|
+
"target": target,
|
|
105
|
+
"attributes": merged,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _request_id(payload: dict[str, Any], explicit: str | None) -> str:
|
|
110
|
+
if explicit:
|
|
111
|
+
return explicit
|
|
112
|
+
native = payload.get("id")
|
|
113
|
+
if isinstance(native, str) and native:
|
|
114
|
+
return native
|
|
115
|
+
seed = {
|
|
116
|
+
"model": payload.get("model"),
|
|
117
|
+
"created": payload.get("created"),
|
|
118
|
+
"usage": payload.get("usage"),
|
|
119
|
+
}
|
|
120
|
+
raw = json.dumps(seed, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
121
|
+
return hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()[:32]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _copy_int(mapping: dict[str, Any], source: str, target: str, attrs: dict[str, Any]) -> None:
|
|
125
|
+
value = mapping.get(source)
|
|
126
|
+
if isinstance(value, int) and not isinstance(value, bool) and target not in attrs:
|
|
127
|
+
attrs[target] = value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _usage_attributes(payload: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
attrs: dict[str, Any] = {"protocol": "openai_compatible"}
|
|
132
|
+
usage = payload.get("usage")
|
|
133
|
+
if not isinstance(usage, dict):
|
|
134
|
+
return attrs
|
|
135
|
+
for source, target in (
|
|
136
|
+
("prompt_tokens", "prompt_tokens"),
|
|
137
|
+
("input_tokens", "prompt_tokens"),
|
|
138
|
+
("completion_tokens", "completion_tokens"),
|
|
139
|
+
("output_tokens", "completion_tokens"),
|
|
140
|
+
("total_tokens", "total_tokens"),
|
|
141
|
+
):
|
|
142
|
+
_copy_int(usage, source, target, attrs)
|
|
143
|
+
cost = usage.get("cost")
|
|
144
|
+
if isinstance(cost, (int, float)) and not isinstance(cost, bool):
|
|
145
|
+
attrs["cost_usd"] = float(cost)
|
|
146
|
+
for detail_key in ("prompt_tokens_details", "input_tokens_details"):
|
|
147
|
+
details = usage.get(detail_key)
|
|
148
|
+
if not isinstance(details, dict):
|
|
149
|
+
continue
|
|
150
|
+
for source, target in (
|
|
151
|
+
("cached_tokens", "cached_prompt_tokens"),
|
|
152
|
+
("cache_write_tokens", "cache_write_tokens"),
|
|
153
|
+
):
|
|
154
|
+
_copy_int(details, source, target, attrs)
|
|
155
|
+
for detail_key in ("completion_tokens_details", "output_tokens_details"):
|
|
156
|
+
details = usage.get(detail_key)
|
|
157
|
+
if isinstance(details, dict):
|
|
158
|
+
_copy_int(details, "reasoning_tokens", "reasoning_tokens", attrs)
|
|
159
|
+
for source, target in (
|
|
160
|
+
("cache_creation_input_tokens", "cache_write_tokens"),
|
|
161
|
+
("cache_read_input_tokens", "cached_prompt_tokens"),
|
|
162
|
+
):
|
|
163
|
+
_copy_int(usage, source, target, attrs)
|
|
164
|
+
return attrs
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def gateway_response_to_events(
|
|
168
|
+
payload: dict[str, Any],
|
|
169
|
+
*,
|
|
170
|
+
gateway_name: str,
|
|
171
|
+
endpoint: str,
|
|
172
|
+
requested_model: str | None = None,
|
|
173
|
+
resolved_model: str | None = None,
|
|
174
|
+
provider_name: str | None = None,
|
|
175
|
+
deployment_id: str | None = None,
|
|
176
|
+
request_id: str | None = None,
|
|
177
|
+
timestamp: str | None = None,
|
|
178
|
+
) -> list[dict[str, Any]]:
|
|
179
|
+
observed_at = timestamp or _now()
|
|
180
|
+
gateway = _gateway_entity(gateway_name, endpoint)
|
|
181
|
+
native_id = _request_id(payload, request_id)
|
|
182
|
+
response_model = payload.get("model")
|
|
183
|
+
resolved = resolved_model
|
|
184
|
+
if not isinstance(resolved, str) or not resolved:
|
|
185
|
+
resolved = response_model if isinstance(response_model, str) and response_model else None
|
|
186
|
+
|
|
187
|
+
attrs = _usage_attributes(payload)
|
|
188
|
+
if isinstance(requested_model, str) and requested_model:
|
|
189
|
+
attrs["requested_model"] = requested_model
|
|
190
|
+
if isinstance(resolved, str) and resolved:
|
|
191
|
+
attrs["resolved_model"] = resolved
|
|
192
|
+
if isinstance(provider_name, str) and provider_name:
|
|
193
|
+
attrs["provider_name"] = provider_name
|
|
194
|
+
if isinstance(deployment_id, str) and deployment_id:
|
|
195
|
+
attrs["deployment_id"] = deployment_id
|
|
196
|
+
|
|
197
|
+
request = _entity(
|
|
198
|
+
"inference_request",
|
|
199
|
+
f"inference-request:{gateway_name}:{native_id}",
|
|
200
|
+
name=native_id,
|
|
201
|
+
attributes={"gateway": gateway_name, **attrs},
|
|
202
|
+
)
|
|
203
|
+
events = [
|
|
204
|
+
_event(
|
|
205
|
+
timestamp=observed_at,
|
|
206
|
+
event_type=f"inference_gateway.{gateway_name}.response.observed",
|
|
207
|
+
relation="SERVED_INFERENCE",
|
|
208
|
+
source=gateway,
|
|
209
|
+
target=request,
|
|
210
|
+
gateway=gateway_name,
|
|
211
|
+
attributes=attrs,
|
|
212
|
+
)
|
|
213
|
+
]
|
|
214
|
+
if isinstance(requested_model, str) and requested_model:
|
|
215
|
+
events.append(
|
|
216
|
+
_event(
|
|
217
|
+
timestamp=observed_at,
|
|
218
|
+
event_type=f"inference_gateway.{gateway_name}.model.requested",
|
|
219
|
+
relation="REQUESTED_MODEL",
|
|
220
|
+
source=request,
|
|
221
|
+
target=_model_entity(requested_model),
|
|
222
|
+
gateway=gateway_name,
|
|
223
|
+
attributes=attrs,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
if isinstance(resolved, str) and resolved:
|
|
227
|
+
events.append(
|
|
228
|
+
_event(
|
|
229
|
+
timestamp=observed_at,
|
|
230
|
+
event_type=f"inference_gateway.{gateway_name}.model.resolved",
|
|
231
|
+
relation="ROUTED_TO_MODEL",
|
|
232
|
+
source=request,
|
|
233
|
+
target=_model_entity(resolved),
|
|
234
|
+
gateway=gateway_name,
|
|
235
|
+
attributes=attrs,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
if isinstance(provider_name, str) and provider_name:
|
|
239
|
+
events.append(
|
|
240
|
+
_event(
|
|
241
|
+
timestamp=observed_at,
|
|
242
|
+
event_type=f"inference_gateway.{gateway_name}.provider.resolved",
|
|
243
|
+
relation="ROUTED_TO_PROVIDER",
|
|
244
|
+
source=request,
|
|
245
|
+
target=_provider_entity(provider_name),
|
|
246
|
+
gateway=gateway_name,
|
|
247
|
+
attributes=attrs,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
if isinstance(deployment_id, str) and deployment_id:
|
|
251
|
+
events.append(
|
|
252
|
+
_event(
|
|
253
|
+
timestamp=observed_at,
|
|
254
|
+
event_type=f"inference_gateway.{gateway_name}.deployment.resolved",
|
|
255
|
+
relation="ROUTED_TO_DEPLOYMENT",
|
|
256
|
+
source=request,
|
|
257
|
+
target=_deployment_entity(gateway_name, deployment_id),
|
|
258
|
+
gateway=gateway_name,
|
|
259
|
+
attributes=attrs,
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
return events
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def openrouter_response_to_events(
|
|
266
|
+
payload: dict[str, Any],
|
|
267
|
+
*,
|
|
268
|
+
requested_model: str | None = None,
|
|
269
|
+
resolved_model: str | None = None,
|
|
270
|
+
provider_name: str | None = None,
|
|
271
|
+
deployment_id: str | None = None,
|
|
272
|
+
endpoint: str = "https://openrouter.ai/api/v1",
|
|
273
|
+
request_id: str | None = None,
|
|
274
|
+
timestamp: str | None = None,
|
|
275
|
+
) -> list[dict[str, Any]]:
|
|
276
|
+
return gateway_response_to_events(
|
|
277
|
+
payload,
|
|
278
|
+
gateway_name="openrouter",
|
|
279
|
+
endpoint=endpoint,
|
|
280
|
+
requested_model=requested_model,
|
|
281
|
+
resolved_model=resolved_model,
|
|
282
|
+
provider_name=provider_name,
|
|
283
|
+
deployment_id=deployment_id,
|
|
284
|
+
request_id=request_id,
|
|
285
|
+
timestamp=timestamp,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def litellm_response_to_events(
|
|
290
|
+
payload: dict[str, Any],
|
|
291
|
+
*,
|
|
292
|
+
requested_model: str | None = None,
|
|
293
|
+
resolved_model: str | None = None,
|
|
294
|
+
provider_name: str | None = None,
|
|
295
|
+
deployment_id: str | None = None,
|
|
296
|
+
endpoint: str = "http://localhost:4000",
|
|
297
|
+
request_id: str | None = None,
|
|
298
|
+
timestamp: str | None = None,
|
|
299
|
+
) -> list[dict[str, Any]]:
|
|
300
|
+
return gateway_response_to_events(
|
|
301
|
+
payload,
|
|
302
|
+
gateway_name="litellm",
|
|
303
|
+
endpoint=endpoint,
|
|
304
|
+
requested_model=requested_model,
|
|
305
|
+
resolved_model=resolved_model,
|
|
306
|
+
provider_name=provider_name,
|
|
307
|
+
deployment_id=deployment_id,
|
|
308
|
+
request_id=request_id,
|
|
309
|
+
timestamp=timestamp,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def openrouter_generation_to_events(
|
|
314
|
+
payload: dict[str, Any],
|
|
315
|
+
*,
|
|
316
|
+
endpoint: str = "https://openrouter.ai/api/v1",
|
|
317
|
+
timestamp: str | None = None,
|
|
318
|
+
) -> list[dict[str, Any]]:
|
|
319
|
+
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
|
320
|
+
if not isinstance(data, dict):
|
|
321
|
+
raise ValueError("OpenRouter generation payload must be a JSON object")
|
|
322
|
+
generation_id = data.get("id")
|
|
323
|
+
if not isinstance(generation_id, str) or not generation_id:
|
|
324
|
+
raise ValueError("OpenRouter generation metadata requires id")
|
|
325
|
+
observed_at = timestamp or _now()
|
|
326
|
+
gateway = _gateway_entity("openrouter", endpoint)
|
|
327
|
+
|
|
328
|
+
attrs: dict[str, Any] = {"protocol": "openrouter_generation"}
|
|
329
|
+
for key in (
|
|
330
|
+
"latency",
|
|
331
|
+
"generation_time",
|
|
332
|
+
"total_cost",
|
|
333
|
+
"tokens_prompt",
|
|
334
|
+
"tokens_completion",
|
|
335
|
+
"native_tokens_prompt",
|
|
336
|
+
"native_tokens_completion",
|
|
337
|
+
):
|
|
338
|
+
value = data.get(key)
|
|
339
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
340
|
+
attrs[key] = value
|
|
341
|
+
for key in ("streamed", "cancelled"):
|
|
342
|
+
value = data.get(key)
|
|
343
|
+
if isinstance(value, bool):
|
|
344
|
+
attrs[key] = value
|
|
345
|
+
|
|
346
|
+
request = _entity(
|
|
347
|
+
"inference_request",
|
|
348
|
+
f"inference-request:openrouter:{generation_id}",
|
|
349
|
+
name=generation_id,
|
|
350
|
+
attributes={"gateway": "openrouter", **attrs},
|
|
351
|
+
)
|
|
352
|
+
events = [
|
|
353
|
+
_event(
|
|
354
|
+
timestamp=observed_at,
|
|
355
|
+
event_type="inference_gateway.openrouter.generation.observed",
|
|
356
|
+
relation="REPORTED_GENERATION_METADATA",
|
|
357
|
+
source=gateway,
|
|
358
|
+
target=request,
|
|
359
|
+
gateway="openrouter",
|
|
360
|
+
attributes=attrs,
|
|
361
|
+
)
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
model = data.get("model") or data.get("model_name")
|
|
365
|
+
if isinstance(model, str) and model:
|
|
366
|
+
events.append(
|
|
367
|
+
_event(
|
|
368
|
+
timestamp=observed_at,
|
|
369
|
+
event_type="inference_gateway.openrouter.model.resolved",
|
|
370
|
+
relation="ROUTED_TO_MODEL",
|
|
371
|
+
source=request,
|
|
372
|
+
target=_model_entity(model),
|
|
373
|
+
gateway="openrouter",
|
|
374
|
+
attributes=attrs,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
provider = data.get("provider_name") or data.get("provider")
|
|
378
|
+
if isinstance(provider, str) and provider:
|
|
379
|
+
events.append(
|
|
380
|
+
_event(
|
|
381
|
+
timestamp=observed_at,
|
|
382
|
+
event_type="inference_gateway.openrouter.provider.resolved",
|
|
383
|
+
relation="ROUTED_TO_PROVIDER",
|
|
384
|
+
source=request,
|
|
385
|
+
target=_provider_entity(provider),
|
|
386
|
+
gateway="openrouter",
|
|
387
|
+
attributes=attrs,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
return events
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def append_gateway_records(path: str | Path, records: list[dict[str, Any]]) -> Path:
|
|
394
|
+
output = Path(path).expanduser().resolve()
|
|
395
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
if not records:
|
|
397
|
+
return output
|
|
398
|
+
blob = "".join(
|
|
399
|
+
json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
|
|
400
|
+
for record in records
|
|
401
|
+
)
|
|
402
|
+
lock_dir = output.with_name(output.name + ".lock")
|
|
403
|
+
deadline = time.monotonic() + 5.0
|
|
404
|
+
while True:
|
|
405
|
+
try:
|
|
406
|
+
lock_dir.mkdir()
|
|
407
|
+
break
|
|
408
|
+
except FileExistsError:
|
|
409
|
+
if time.monotonic() >= deadline:
|
|
410
|
+
raise TimeoutError(f"timed out waiting for inference gateway sidecar lock: {lock_dir}")
|
|
411
|
+
time.sleep(0.01)
|
|
412
|
+
try:
|
|
413
|
+
with output.open("a", encoding="utf-8", newline="\n") as handle:
|
|
414
|
+
handle.write(blob)
|
|
415
|
+
handle.flush()
|
|
416
|
+
os.fsync(handle.fileno())
|
|
417
|
+
finally:
|
|
418
|
+
try:
|
|
419
|
+
lock_dir.rmdir()
|
|
420
|
+
except OSError:
|
|
421
|
+
pass
|
|
422
|
+
return output
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .inference_gateway import (
|
|
10
|
+
append_gateway_records,
|
|
11
|
+
litellm_response_to_events,
|
|
12
|
+
openrouter_generation_to_events,
|
|
13
|
+
openrouter_response_to_events,
|
|
14
|
+
sanitize_gateway_endpoint,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_GATEWAYS = ("openrouter", "litellm")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sidecar(value: Path | None) -> Path:
|
|
21
|
+
if value is not None:
|
|
22
|
+
return value
|
|
23
|
+
configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
|
|
24
|
+
if configured:
|
|
25
|
+
return Path(configured)
|
|
26
|
+
raise ValueError("--sidecar or EXECWEAVE_SEMANTIC_SIDECAR is required")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_json_stdin() -> dict:
|
|
30
|
+
raw = sys.stdin.read()
|
|
31
|
+
if not raw.strip():
|
|
32
|
+
raise ValueError("stdin is empty")
|
|
33
|
+
try:
|
|
34
|
+
payload = json.loads(raw)
|
|
35
|
+
except json.JSONDecodeError as exc:
|
|
36
|
+
raise ValueError(f"stdin is invalid JSON: {exc.msg}") from exc
|
|
37
|
+
if not isinstance(payload, dict):
|
|
38
|
+
raise ValueError("stdin must contain one JSON object")
|
|
39
|
+
return payload
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _default_endpoint(gateway: str) -> str:
|
|
43
|
+
return {
|
|
44
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
45
|
+
"litellm": "http://localhost:4000",
|
|
46
|
+
}[gateway]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="execweave-inference-gateway",
|
|
52
|
+
description="Capture inference gateway routing/usage metadata without prompt or response content.",
|
|
53
|
+
)
|
|
54
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
event = sub.add_parser("event", help="Convert one final gateway response into metadata events.")
|
|
57
|
+
event.add_argument("--gateway", choices=_GATEWAYS, required=True)
|
|
58
|
+
event.add_argument("--endpoint", default=None)
|
|
59
|
+
event.add_argument("--requested-model", default=None)
|
|
60
|
+
event.add_argument("--resolved-model", default=None)
|
|
61
|
+
event.add_argument("--provider-name", default=None)
|
|
62
|
+
event.add_argument("--deployment-id", default=None)
|
|
63
|
+
event.add_argument("--request-id", default=None)
|
|
64
|
+
event.add_argument("--sidecar", type=Path, default=None)
|
|
65
|
+
|
|
66
|
+
generation = sub.add_parser(
|
|
67
|
+
"generation",
|
|
68
|
+
help="Convert OpenRouter generation metadata into routing/performance events.",
|
|
69
|
+
)
|
|
70
|
+
generation.add_argument("--gateway", choices=["openrouter"], required=True)
|
|
71
|
+
generation.add_argument("--endpoint", default=None)
|
|
72
|
+
generation.add_argument("--sidecar", type=Path, default=None)
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: list[str] | None = None) -> int:
|
|
77
|
+
parser = build_parser()
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
try:
|
|
80
|
+
payload = _read_json_stdin()
|
|
81
|
+
endpoint = sanitize_gateway_endpoint(args.endpoint or _default_endpoint(args.gateway))
|
|
82
|
+
if args.command == "event":
|
|
83
|
+
converters = {
|
|
84
|
+
"openrouter": openrouter_response_to_events,
|
|
85
|
+
"litellm": litellm_response_to_events,
|
|
86
|
+
}
|
|
87
|
+
records = converters[args.gateway](
|
|
88
|
+
payload,
|
|
89
|
+
requested_model=args.requested_model,
|
|
90
|
+
resolved_model=args.resolved_model,
|
|
91
|
+
provider_name=args.provider_name,
|
|
92
|
+
deployment_id=args.deployment_id,
|
|
93
|
+
endpoint=endpoint,
|
|
94
|
+
request_id=args.request_id,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
records = openrouter_generation_to_events(payload, endpoint=endpoint)
|
|
98
|
+
output = append_gateway_records(_sidecar(args.sidecar), records)
|
|
99
|
+
except (OSError, TimeoutError, ValueError) as exc:
|
|
100
|
+
parser.error(str(exc))
|
|
101
|
+
print(json.dumps({"records": len(records), "sidecar": str(output)}, sort_keys=True))
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _now() -> str:
|
|
9
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _entity(
|
|
13
|
+
entity_type: str,
|
|
14
|
+
entity_id: str,
|
|
15
|
+
*,
|
|
16
|
+
name: str | None = None,
|
|
17
|
+
attributes: dict[str, Any] | None = None,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
return {"type": entity_type, "id": entity_id, "name": name, "attributes": attributes or {}}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _required(value: str, *, field: str) -> str:
|
|
23
|
+
if not isinstance(value, str) or not value.strip():
|
|
24
|
+
raise ValueError(f"{field} must be a non-empty string")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def gateway_runtime_identity_event(
|
|
29
|
+
*,
|
|
30
|
+
gateway: str,
|
|
31
|
+
gateway_request_id: str,
|
|
32
|
+
runtime: str,
|
|
33
|
+
runtime_request_id: str,
|
|
34
|
+
shared_request_id: str,
|
|
35
|
+
timestamp: str | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
gateway = _required(gateway, field="gateway")
|
|
38
|
+
gateway_request_id = _required(gateway_request_id, field="gateway_request_id")
|
|
39
|
+
runtime = _required(runtime, field="runtime")
|
|
40
|
+
runtime_request_id = _required(runtime_request_id, field="runtime_request_id")
|
|
41
|
+
shared_request_id = _required(shared_request_id, field="shared_request_id")
|
|
42
|
+
|
|
43
|
+
shared_hash = hashlib.sha256(
|
|
44
|
+
shared_request_id.encode("utf-8", errors="replace")
|
|
45
|
+
).hexdigest()[:32]
|
|
46
|
+
source = _entity(
|
|
47
|
+
"inference_request",
|
|
48
|
+
f"inference-request:{gateway}:{gateway_request_id}",
|
|
49
|
+
name=gateway_request_id,
|
|
50
|
+
attributes={"gateway": gateway},
|
|
51
|
+
)
|
|
52
|
+
target = _entity(
|
|
53
|
+
"inference_request",
|
|
54
|
+
f"inference-request:{runtime}:{runtime_request_id}",
|
|
55
|
+
name=runtime_request_id,
|
|
56
|
+
attributes={"provider": runtime},
|
|
57
|
+
)
|
|
58
|
+
return {
|
|
59
|
+
"timestamp": timestamp or _now(),
|
|
60
|
+
"event_type": "inference_identity.gateway_runtime.exact",
|
|
61
|
+
"relation": "SAME_INFERENCE_REQUEST",
|
|
62
|
+
"source": source,
|
|
63
|
+
"target": target,
|
|
64
|
+
"attributes": {
|
|
65
|
+
"backend": "cross_layer_identity",
|
|
66
|
+
"attribution": "explicit_shared_request_id",
|
|
67
|
+
"evidence_source": "caller_supplied_shared_request_id",
|
|
68
|
+
"gateway": gateway,
|
|
69
|
+
"runtime": runtime,
|
|
70
|
+
"causal": False,
|
|
71
|
+
"inferred": False,
|
|
72
|
+
"identity_exact": True,
|
|
73
|
+
"identity_method": "shared_request_id",
|
|
74
|
+
"shared_request_id_hash": shared_hash,
|
|
75
|
+
},
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .inference_gateway import append_gateway_records
|
|
9
|
+
from .inference_identity import gateway_runtime_identity_event
|
|
10
|
+
|
|
11
|
+
_GATEWAYS = ("openrouter", "litellm")
|
|
12
|
+
_RUNTIMES = ("ollama", "llamacpp", "vllm", "lmstudio")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sidecar(value: Path | None) -> Path:
|
|
16
|
+
if value is not None:
|
|
17
|
+
return value
|
|
18
|
+
configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
|
|
19
|
+
if configured:
|
|
20
|
+
return Path(configured)
|
|
21
|
+
raise ValueError("--sidecar or EXECWEAVE_SEMANTIC_SIDECAR is required")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="execweave-inference-link",
|
|
27
|
+
description=(
|
|
28
|
+
"Link gateway and model-runtime request nodes only when an explicit shared request "
|
|
29
|
+
"identity is available."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--gateway", choices=_GATEWAYS, required=True)
|
|
33
|
+
parser.add_argument("--gateway-request-id", required=True)
|
|
34
|
+
parser.add_argument("--runtime", choices=_RUNTIMES, required=True)
|
|
35
|
+
parser.add_argument("--runtime-request-id", required=True)
|
|
36
|
+
parser.add_argument("--shared-request-id", required=True)
|
|
37
|
+
parser.add_argument("--sidecar", type=Path, default=None)
|
|
38
|
+
return parser
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: list[str] | None = None) -> int:
|
|
42
|
+
parser = build_parser()
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
try:
|
|
45
|
+
record = gateway_runtime_identity_event(
|
|
46
|
+
gateway=args.gateway,
|
|
47
|
+
gateway_request_id=args.gateway_request_id,
|
|
48
|
+
runtime=args.runtime,
|
|
49
|
+
runtime_request_id=args.runtime_request_id,
|
|
50
|
+
shared_request_id=args.shared_request_id,
|
|
51
|
+
)
|
|
52
|
+
output = append_gateway_records(_sidecar(args.sidecar), [record])
|
|
53
|
+
except (OSError, TimeoutError, ValueError) as exc:
|
|
54
|
+
parser.error(str(exc))
|
|
55
|
+
print(json.dumps({"records": 1, "sidecar": str(output)}, sort_keys=True))
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
raise SystemExit(main())
|