pi-codemcp 1.0.0 → 1.1.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/README.md +144 -13
- package/extensions/index.ts +4 -1
- package/package.json +7 -7
- package/sidecar/catalog_cache.py +2 -1
- package/sidecar/cli.py +402 -0
- package/sidecar/executor.py +349 -36
- package/sidecar/gateway.py +499 -60
- package/sidecar/models.py +41 -4
- package/sidecar/pyproject.toml +33 -40
- package/sidecar/runtime_paths.py +71 -0
- package/sidecar/settings.py +7 -3
- package/sidecar/stats.py +424 -0
- package/sidecar/tool_catalog.py +311 -46
- package/sidecar/uv.lock +99 -244
- package/src/chains.ts +1 -2
- package/src/execution-rendering.ts +7 -5
- package/src/mcp-client.ts +5 -1
- package/src/modal.ts +258 -14
- package/src/output.ts +27 -24
- package/src/prompts.ts +24 -0
- package/src/settings.ts +18 -17
- package/src/tools.ts +270 -36
package/sidecar/stats.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from .json_types import JSON_OBJECT_ADAPTER, JSON_VALUE_ADAPTER, JsonObject
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
HISTOGRAM_BOUNDS_MS = (1, 5, 10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 30_000)
|
|
18
|
+
HISTOGRAM_BOUNDS_BYTES = (64, 256, 1_024, 4_096, 16_384, 65_536, 262_144, 1_048_576)
|
|
19
|
+
RECENT_BUCKET_SECONDS = 60 * 60
|
|
20
|
+
RECENT_BUCKET_COUNT = 24
|
|
21
|
+
FLUSH_DELAY_SECONDS = 5.0
|
|
22
|
+
MAX_OPERATIONS = 32
|
|
23
|
+
MAX_PHASES = 16
|
|
24
|
+
MAX_SERVERS = 64
|
|
25
|
+
MAX_TOOLS = 384
|
|
26
|
+
MAX_FAILURE_STAGES = 16
|
|
27
|
+
OTHER_DIMENSION = "<other>"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Histogram:
|
|
32
|
+
bounds: tuple[int, ...] = HISTOGRAM_BOUNDS_MS
|
|
33
|
+
counts: list[int] = field(default_factory=lambda: [0] * (len(HISTOGRAM_BOUNDS_MS) + 1))
|
|
34
|
+
count: int = 0
|
|
35
|
+
total: float = 0.0
|
|
36
|
+
maximum: float = 0.0
|
|
37
|
+
|
|
38
|
+
def observe(self, value: float) -> None:
|
|
39
|
+
bounded = max(0.0, value)
|
|
40
|
+
self.count += 1
|
|
41
|
+
self.total += bounded
|
|
42
|
+
self.maximum = max(self.maximum, bounded)
|
|
43
|
+
for index, bound in enumerate(self.bounds):
|
|
44
|
+
if bounded <= bound:
|
|
45
|
+
self.counts[index] += 1
|
|
46
|
+
return
|
|
47
|
+
self.counts[-1] += 1
|
|
48
|
+
|
|
49
|
+
def snapshot(self) -> JsonObject:
|
|
50
|
+
buckets: list[JsonObject] = [
|
|
51
|
+
{"le": bound, "count": count}
|
|
52
|
+
for bound, count in zip(self.bounds, self.counts[:-1], strict=True)
|
|
53
|
+
]
|
|
54
|
+
buckets.append({"le": "inf", "count": self.counts[-1]})
|
|
55
|
+
return {
|
|
56
|
+
"count": self.count,
|
|
57
|
+
"sum": round(self.total, 3),
|
|
58
|
+
"average": round(self.total / self.count, 3) if self.count else 0.0,
|
|
59
|
+
"max": round(self.maximum, 3),
|
|
60
|
+
"buckets": JSON_VALUE_ADAPTER.validate_python(buckets),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_snapshot(
|
|
65
|
+
cls,
|
|
66
|
+
value: object,
|
|
67
|
+
*,
|
|
68
|
+
bounds: tuple[int, ...] = HISTOGRAM_BOUNDS_MS,
|
|
69
|
+
) -> Histogram:
|
|
70
|
+
histogram = cls(bounds=bounds, counts=[0] * (len(bounds) + 1))
|
|
71
|
+
if not isinstance(value, dict):
|
|
72
|
+
return histogram
|
|
73
|
+
histogram.count = _integer(value.get("count"))
|
|
74
|
+
histogram.total = _number(value.get("sum"))
|
|
75
|
+
histogram.maximum = _number(value.get("max"))
|
|
76
|
+
raw_buckets = value.get("buckets")
|
|
77
|
+
if isinstance(raw_buckets, list) and len(raw_buckets) == len(histogram.counts):
|
|
78
|
+
histogram.counts = [
|
|
79
|
+
_integer(bucket.get("count")) if isinstance(bucket, dict) else 0
|
|
80
|
+
for bucket in raw_buckets
|
|
81
|
+
]
|
|
82
|
+
return histogram
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Rollup:
|
|
87
|
+
count: int = 0
|
|
88
|
+
success: int = 0
|
|
89
|
+
failure: int = 0
|
|
90
|
+
input_bytes: int = 0
|
|
91
|
+
output_bytes: int = 0
|
|
92
|
+
calls: int = 0
|
|
93
|
+
chain_calls: int = 0
|
|
94
|
+
duration_ms: Histogram = field(default_factory=Histogram)
|
|
95
|
+
input_size_bytes: Histogram = field(
|
|
96
|
+
default_factory=lambda: Histogram(
|
|
97
|
+
bounds=HISTOGRAM_BOUNDS_BYTES,
|
|
98
|
+
counts=[0] * (len(HISTOGRAM_BOUNDS_BYTES) + 1),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
output_size_bytes: Histogram = field(
|
|
102
|
+
default_factory=lambda: Histogram(
|
|
103
|
+
bounds=HISTOGRAM_BOUNDS_BYTES,
|
|
104
|
+
counts=[0] * (len(HISTOGRAM_BOUNDS_BYTES) + 1),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def observe(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
duration_ms: float,
|
|
112
|
+
success: bool,
|
|
113
|
+
input_bytes: int = 0,
|
|
114
|
+
output_bytes: int = 0,
|
|
115
|
+
calls: int = 0,
|
|
116
|
+
chain_calls: int = 0,
|
|
117
|
+
) -> None:
|
|
118
|
+
self.count += 1
|
|
119
|
+
if success:
|
|
120
|
+
self.success += 1
|
|
121
|
+
else:
|
|
122
|
+
self.failure += 1
|
|
123
|
+
self.input_bytes += max(0, input_bytes)
|
|
124
|
+
self.output_bytes += max(0, output_bytes)
|
|
125
|
+
self.calls += max(0, calls)
|
|
126
|
+
self.chain_calls += max(0, chain_calls)
|
|
127
|
+
self.duration_ms.observe(duration_ms)
|
|
128
|
+
self.input_size_bytes.observe(float(max(0, input_bytes)))
|
|
129
|
+
self.output_size_bytes.observe(float(max(0, output_bytes)))
|
|
130
|
+
|
|
131
|
+
def snapshot(self, *, include_distributions: bool = True) -> JsonObject:
|
|
132
|
+
values: JsonObject = {
|
|
133
|
+
"count": self.count,
|
|
134
|
+
"success": self.success,
|
|
135
|
+
"failure": self.failure,
|
|
136
|
+
"input_bytes": self.input_bytes,
|
|
137
|
+
"output_bytes": self.output_bytes,
|
|
138
|
+
"calls": self.calls,
|
|
139
|
+
"chain_calls": self.chain_calls,
|
|
140
|
+
}
|
|
141
|
+
if include_distributions:
|
|
142
|
+
values["duration_ms"] = self.duration_ms.snapshot()
|
|
143
|
+
values["input_size_bytes"] = self.input_size_bytes.snapshot()
|
|
144
|
+
values["output_size_bytes"] = self.output_size_bytes.snapshot()
|
|
145
|
+
return values
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_snapshot(cls, value: object) -> Rollup:
|
|
149
|
+
if not isinstance(value, dict):
|
|
150
|
+
return cls()
|
|
151
|
+
return cls(
|
|
152
|
+
count=_integer(value.get("count")),
|
|
153
|
+
success=_integer(value.get("success")),
|
|
154
|
+
failure=_integer(value.get("failure")),
|
|
155
|
+
input_bytes=_integer(value.get("input_bytes")),
|
|
156
|
+
output_bytes=_integer(value.get("output_bytes")),
|
|
157
|
+
calls=_integer(value.get("calls")),
|
|
158
|
+
chain_calls=_integer(value.get("chain_calls")),
|
|
159
|
+
duration_ms=Histogram.from_snapshot(value.get("duration_ms")),
|
|
160
|
+
input_size_bytes=Histogram.from_snapshot(
|
|
161
|
+
value.get("input_size_bytes"), bounds=HISTOGRAM_BOUNDS_BYTES
|
|
162
|
+
),
|
|
163
|
+
output_size_bytes=Histogram.from_snapshot(
|
|
164
|
+
value.get("output_size_bytes"), bounds=HISTOGRAM_BOUNDS_BYTES
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class StatsStore:
|
|
170
|
+
def __init__(self, path: Path) -> None:
|
|
171
|
+
self.path = path
|
|
172
|
+
self.lifetime = Rollup()
|
|
173
|
+
self.operations: dict[str, Rollup] = {}
|
|
174
|
+
self.phases: dict[str, Histogram] = {}
|
|
175
|
+
self.servers: dict[str, Rollup] = {}
|
|
176
|
+
self.tools: dict[str, Rollup] = {}
|
|
177
|
+
self.failures: dict[str, int] = {}
|
|
178
|
+
self.cache_hits = 0
|
|
179
|
+
self.cache_misses = 0
|
|
180
|
+
self.recent: dict[int, Rollup] = {}
|
|
181
|
+
self.updated_at = 0
|
|
182
|
+
self._dirty = False
|
|
183
|
+
self._closing = False
|
|
184
|
+
self._flush_task: asyncio.Task[None] | None = None
|
|
185
|
+
self._load()
|
|
186
|
+
|
|
187
|
+
def record_operation(
|
|
188
|
+
self,
|
|
189
|
+
name: str,
|
|
190
|
+
*,
|
|
191
|
+
duration_ms: float,
|
|
192
|
+
success: bool,
|
|
193
|
+
failure_stage: str | None = None,
|
|
194
|
+
input_bytes: int = 0,
|
|
195
|
+
output_bytes: int = 0,
|
|
196
|
+
calls: int = 0,
|
|
197
|
+
chain_calls: int = 0,
|
|
198
|
+
) -> None:
|
|
199
|
+
self.lifetime.observe(
|
|
200
|
+
duration_ms=duration_ms,
|
|
201
|
+
success=success,
|
|
202
|
+
input_bytes=input_bytes,
|
|
203
|
+
output_bytes=output_bytes,
|
|
204
|
+
calls=calls,
|
|
205
|
+
chain_calls=chain_calls,
|
|
206
|
+
)
|
|
207
|
+
self._rollup_dimension(self.operations, name, MAX_OPERATIONS).observe(
|
|
208
|
+
duration_ms=duration_ms,
|
|
209
|
+
success=success,
|
|
210
|
+
input_bytes=input_bytes,
|
|
211
|
+
output_bytes=output_bytes,
|
|
212
|
+
calls=calls,
|
|
213
|
+
chain_calls=chain_calls,
|
|
214
|
+
)
|
|
215
|
+
self._recent_rollup().observe(
|
|
216
|
+
duration_ms=duration_ms,
|
|
217
|
+
success=success,
|
|
218
|
+
input_bytes=input_bytes,
|
|
219
|
+
output_bytes=output_bytes,
|
|
220
|
+
calls=calls,
|
|
221
|
+
chain_calls=chain_calls,
|
|
222
|
+
)
|
|
223
|
+
if failure_stage is not None:
|
|
224
|
+
key = self._bounded_key(self.failures, failure_stage, MAX_FAILURE_STAGES)
|
|
225
|
+
self.failures[key] = self.failures.get(key, 0) + 1
|
|
226
|
+
self._changed()
|
|
227
|
+
|
|
228
|
+
def record_phase(self, name: str, duration_ms: float) -> None:
|
|
229
|
+
self._histogram_dimension(self.phases, name, MAX_PHASES).observe(duration_ms)
|
|
230
|
+
self._changed()
|
|
231
|
+
|
|
232
|
+
def record_upstream(
|
|
233
|
+
self,
|
|
234
|
+
server: str,
|
|
235
|
+
tool: str,
|
|
236
|
+
*,
|
|
237
|
+
duration_ms: float,
|
|
238
|
+
success: bool,
|
|
239
|
+
input_bytes: int,
|
|
240
|
+
output_bytes: int,
|
|
241
|
+
) -> None:
|
|
242
|
+
for rollup in (
|
|
243
|
+
self._rollup_dimension(self.servers, server, MAX_SERVERS),
|
|
244
|
+
self._rollup_dimension(self.tools, f"{server}.{tool}", MAX_TOOLS),
|
|
245
|
+
):
|
|
246
|
+
rollup.observe(
|
|
247
|
+
duration_ms=duration_ms,
|
|
248
|
+
success=success,
|
|
249
|
+
input_bytes=input_bytes,
|
|
250
|
+
output_bytes=output_bytes,
|
|
251
|
+
calls=1,
|
|
252
|
+
)
|
|
253
|
+
self._changed()
|
|
254
|
+
|
|
255
|
+
def record_cache(self, *, hit: bool) -> None:
|
|
256
|
+
if hit:
|
|
257
|
+
self.cache_hits += 1
|
|
258
|
+
else:
|
|
259
|
+
self.cache_misses += 1
|
|
260
|
+
self._changed()
|
|
261
|
+
|
|
262
|
+
def snapshot(self) -> JsonObject:
|
|
263
|
+
recent = [
|
|
264
|
+
{"bucket_start": timestamp, **rollup.snapshot(include_distributions=False)}
|
|
265
|
+
for timestamp, rollup in sorted(self.recent.items())
|
|
266
|
+
]
|
|
267
|
+
return JSON_OBJECT_ADAPTER.validate_python({
|
|
268
|
+
"version": 1,
|
|
269
|
+
"updated_at": self.updated_at,
|
|
270
|
+
"lifetime": self.lifetime.snapshot(),
|
|
271
|
+
"recent": recent,
|
|
272
|
+
"operations": _snapshot_mapping(self.operations),
|
|
273
|
+
"phases": _snapshot_mapping(self.phases),
|
|
274
|
+
"servers": _snapshot_mapping(self.servers),
|
|
275
|
+
"tools": _snapshot_mapping(self.tools, include_distributions=False),
|
|
276
|
+
"failures": dict(sorted(self.failures.items())),
|
|
277
|
+
"cache": {"hits": self.cache_hits, "misses": self.cache_misses},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
def schedule_flush(self) -> None:
|
|
281
|
+
if self._closing:
|
|
282
|
+
return
|
|
283
|
+
if self._flush_task is not None and not self._flush_task.done():
|
|
284
|
+
return
|
|
285
|
+
try:
|
|
286
|
+
loop = asyncio.get_running_loop()
|
|
287
|
+
except RuntimeError:
|
|
288
|
+
return
|
|
289
|
+
self._flush_task = loop.create_task(self._delayed_flush())
|
|
290
|
+
|
|
291
|
+
async def flush(self) -> None:
|
|
292
|
+
if not self._dirty:
|
|
293
|
+
return
|
|
294
|
+
payload = self.snapshot()
|
|
295
|
+
self._dirty = False
|
|
296
|
+
try:
|
|
297
|
+
await asyncio.to_thread(self._write, payload)
|
|
298
|
+
except BaseException:
|
|
299
|
+
self._dirty = True
|
|
300
|
+
raise
|
|
301
|
+
|
|
302
|
+
async def close(self) -> None:
|
|
303
|
+
self._closing = True
|
|
304
|
+
task, self._flush_task = self._flush_task, None
|
|
305
|
+
if task is not None and not task.done():
|
|
306
|
+
task.cancel()
|
|
307
|
+
with suppress(asyncio.CancelledError):
|
|
308
|
+
await task
|
|
309
|
+
await self.flush()
|
|
310
|
+
|
|
311
|
+
async def _delayed_flush(self) -> None:
|
|
312
|
+
try:
|
|
313
|
+
await asyncio.sleep(FLUSH_DELAY_SECONDS)
|
|
314
|
+
await self.flush()
|
|
315
|
+
finally:
|
|
316
|
+
self._flush_task = None
|
|
317
|
+
if self._dirty and not self._closing:
|
|
318
|
+
self.schedule_flush()
|
|
319
|
+
|
|
320
|
+
def _changed(self) -> None:
|
|
321
|
+
self.updated_at = int(time.time())
|
|
322
|
+
self._dirty = True
|
|
323
|
+
self.schedule_flush()
|
|
324
|
+
|
|
325
|
+
def _recent_rollup(self) -> Rollup:
|
|
326
|
+
timestamp = int(time.time() // RECENT_BUCKET_SECONDS) * RECENT_BUCKET_SECONDS
|
|
327
|
+
rollup = self.recent.setdefault(timestamp, Rollup())
|
|
328
|
+
while len(self.recent) > RECENT_BUCKET_COUNT:
|
|
329
|
+
del self.recent[min(self.recent)]
|
|
330
|
+
return rollup
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def _rollup_dimension(values: dict[str, Rollup], key: str, limit: int) -> Rollup:
|
|
334
|
+
bounded = StatsStore._bounded_key(values, key, limit)
|
|
335
|
+
return values.setdefault(bounded, Rollup())
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _histogram_dimension(values: dict[str, Histogram], key: str, limit: int) -> Histogram:
|
|
339
|
+
bounded = StatsStore._bounded_key(values, key, limit)
|
|
340
|
+
return values.setdefault(bounded, Histogram())
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _bounded_key(values: Mapping[str, object], key: str, limit: int) -> str:
|
|
344
|
+
if key in values:
|
|
345
|
+
return key
|
|
346
|
+
if len(values) < max(1, limit - 1):
|
|
347
|
+
return key
|
|
348
|
+
return OTHER_DIMENSION
|
|
349
|
+
|
|
350
|
+
def _write(self, payload: JsonObject) -> None:
|
|
351
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
352
|
+
temporary = self.path.with_name(f"{self.path.name}.{os.getpid()}.tmp")
|
|
353
|
+
temporary.write_text(
|
|
354
|
+
json.dumps(payload, separators=(",", ":"), sort_keys=True),
|
|
355
|
+
encoding="utf-8",
|
|
356
|
+
)
|
|
357
|
+
Path(temporary).replace(self.path)
|
|
358
|
+
|
|
359
|
+
def _load(self) -> None:
|
|
360
|
+
try:
|
|
361
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
362
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
363
|
+
return
|
|
364
|
+
if not isinstance(raw, dict) or raw.get("version") != 1:
|
|
365
|
+
return
|
|
366
|
+
self.updated_at = _integer(raw.get("updated_at"))
|
|
367
|
+
self.lifetime = Rollup.from_snapshot(raw.get("lifetime"))
|
|
368
|
+
self.operations = _load_rollups(raw.get("operations"), MAX_OPERATIONS)
|
|
369
|
+
self.phases = _load_histograms(raw.get("phases"), MAX_PHASES)
|
|
370
|
+
self.servers = _load_rollups(raw.get("servers"), MAX_SERVERS)
|
|
371
|
+
self.tools = _load_rollups(raw.get("tools"), MAX_TOOLS)
|
|
372
|
+
raw_failures = raw.get("failures")
|
|
373
|
+
if isinstance(raw_failures, dict):
|
|
374
|
+
self.failures = {
|
|
375
|
+
str(key): _integer(value)
|
|
376
|
+
for key, value in list(raw_failures.items())[:MAX_FAILURE_STAGES]
|
|
377
|
+
}
|
|
378
|
+
raw_cache = raw.get("cache")
|
|
379
|
+
if isinstance(raw_cache, dict):
|
|
380
|
+
self.cache_hits = _integer(raw_cache.get("hits"))
|
|
381
|
+
self.cache_misses = _integer(raw_cache.get("misses"))
|
|
382
|
+
raw_recent = raw.get("recent")
|
|
383
|
+
if isinstance(raw_recent, list):
|
|
384
|
+
for item in raw_recent[-RECENT_BUCKET_COUNT:]:
|
|
385
|
+
if not isinstance(item, dict):
|
|
386
|
+
continue
|
|
387
|
+
timestamp = _integer(item.get("bucket_start"))
|
|
388
|
+
if timestamp > 0:
|
|
389
|
+
self.recent[timestamp] = Rollup.from_snapshot(item)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _snapshot_mapping(
|
|
393
|
+
values: Mapping[str, Rollup | Histogram],
|
|
394
|
+
*,
|
|
395
|
+
include_distributions: bool = True,
|
|
396
|
+
) -> JsonObject:
|
|
397
|
+
return {
|
|
398
|
+
key: (
|
|
399
|
+
value.snapshot(include_distributions=include_distributions)
|
|
400
|
+
if isinstance(value, Rollup)
|
|
401
|
+
else value.snapshot()
|
|
402
|
+
)
|
|
403
|
+
for key, value in sorted(values.items())
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _load_rollups(value: object, limit: int) -> dict[str, Rollup]:
|
|
408
|
+
if not isinstance(value, dict):
|
|
409
|
+
return {}
|
|
410
|
+
return {str(key): Rollup.from_snapshot(item) for key, item in list(value.items())[:limit]}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _load_histograms(value: object, limit: int) -> dict[str, Histogram]:
|
|
414
|
+
if not isinstance(value, dict):
|
|
415
|
+
return {}
|
|
416
|
+
return {str(key): Histogram.from_snapshot(item) for key, item in list(value.items())[:limit]}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _integer(value: object) -> int:
|
|
420
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _number(value: object) -> float:
|
|
424
|
+
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0.0
|