cortad 0.2.2 → 0.3.0-rc.10
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 +11 -2
- package/lib/cli.mjs +90 -38
- package/lib/home.mjs +9 -2
- package/lib/mcp.mjs +32 -12
- package/lib/pyhook/sitecustomize.py +396 -11
- package/lib/read-text.mjs +91 -0
- package/lib/register.mjs +64 -30
- package/lib/replay.mjs +43 -5
- package/lib/spec.mjs +20 -0
- package/lib/start.mjs +10 -0
- package/lib/stick.mjs +99 -0
- package/lib/text.mjs +268 -0
- package/lib/trace.cjs +303 -9
- package/lib/verbs.mjs +148 -103
- package/lib/words.mjs +37 -0
- package/local.mjs +58 -17
- package/package.json +6 -1
- package/skill/SKILL.md +75 -31
- package/skill/references/results.md +175 -34
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import os
|
|
8
8
|
|
|
9
9
|
_FILE = os.environ.get("CORTAD_TRACE_FILE")
|
|
10
|
+
_RULES = os.environ.get("CORTAD_RULES_FILE")
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def _install():
|
|
@@ -25,6 +26,159 @@ def _install():
|
|
|
25
26
|
model_host = re.compile(r"(?:^|\.)(?:openai\.com|anthropic\.com|fireworks\.ai|openrouter\.ai|groq\.com|mistral\.ai|together\.xyz|together\.ai|deepseek\.com|cohere\.ai|cohere\.com|perplexity\.ai|x\.ai|openai\.azure\.com|cognitiveservices\.azure\.com|replicate\.com|huggingface\.co|cerebras\.ai|deepinfra\.com|novita\.ai|moonshot\.cn|dashscope\.aliyuncs\.com|bigmodel\.cn|ai-gateway\.vercel\.sh|gateway\.ai\.cloudflare\.com|helicone\.ai|portkey\.ai)$", re.I)
|
|
26
27
|
model_path = re.compile(r"/(?:chat/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|/invoke(?:-with-response-stream)?$|/api/(?:chat|generate)$", re.I)
|
|
27
28
|
|
|
29
|
+
# The turn a message came in under, when the run tagged it (one opaque id per request), so a
|
|
30
|
+
# model call and its prompt can be pinned to the reply they produced while turns overlap.
|
|
31
|
+
turn_ok = re.compile(r"^[A-Za-z0-9:_.-]{1,80}$")
|
|
32
|
+
|
|
33
|
+
def turn_now():
|
|
34
|
+
req = ctx.get()
|
|
35
|
+
return req.get("turn") if req else None
|
|
36
|
+
|
|
37
|
+
# The customer's own rule sentences, written beside the trace by the run, so each model call can
|
|
38
|
+
# say which of them its prompt carried. Absent file, nothing is claimed. Reloaded on change.
|
|
39
|
+
rules_at = [-1.0]
|
|
40
|
+
rules = [None]
|
|
41
|
+
slot = re.compile(r"\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>")
|
|
42
|
+
|
|
43
|
+
def norm(s):
|
|
44
|
+
s = str(s or "")
|
|
45
|
+
s = re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), s)
|
|
46
|
+
s = re.sub(r"\\[nrt]", " ", s).replace('\\"', '"').replace("\\\\", "\\")
|
|
47
|
+
return re.sub(r"\s+", " ", s.lower()).strip()
|
|
48
|
+
|
|
49
|
+
def rules_now():
|
|
50
|
+
if not _RULES:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
at = os.stat(_RULES).st_mtime
|
|
54
|
+
if at != rules_at[0]:
|
|
55
|
+
rules_at[0] = at
|
|
56
|
+
with open(_RULES, encoding="utf-8") as f:
|
|
57
|
+
raw = json.load(f)
|
|
58
|
+
out = []
|
|
59
|
+
for r in raw if isinstance(raw, list) else []:
|
|
60
|
+
if not isinstance(r, dict) or not isinstance(r.get("id"), str) or not isinstance(r.get("text"), str):
|
|
61
|
+
continue
|
|
62
|
+
parts = [p for p in (norm(x) for x in slot.split(r["text"])) if len(p) >= 12]
|
|
63
|
+
if parts:
|
|
64
|
+
out.append((r["id"], parts))
|
|
65
|
+
rules[0] = out
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return rules[0]
|
|
69
|
+
|
|
70
|
+
# What the app's tools answered, as the prompt of the next model call carries them (chat tool
|
|
71
|
+
# messages, responses function outputs, Anthropic tool_result blocks, Gemini functionResponse
|
|
72
|
+
# parts): the material a reply's facts rest on. Bounded per call.
|
|
73
|
+
tool_text, tools_max = 3000, 12
|
|
74
|
+
|
|
75
|
+
def text_of(v):
|
|
76
|
+
if isinstance(v, str):
|
|
77
|
+
return v
|
|
78
|
+
if isinstance(v, list):
|
|
79
|
+
return "\n".join(p for p in ((x.get("text") or x.get("content") or "") if isinstance(x, dict) else str(x or "") for x in v) if p)
|
|
80
|
+
if isinstance(v, dict):
|
|
81
|
+
try:
|
|
82
|
+
return json.dumps(v, ensure_ascii=False)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return ""
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
def tools_in(sent):
|
|
88
|
+
body = parsed(sent) if isinstance(sent, str) else None
|
|
89
|
+
if not isinstance(body, dict):
|
|
90
|
+
return None
|
|
91
|
+
out, names = [], {}
|
|
92
|
+
|
|
93
|
+
def add(name, text):
|
|
94
|
+
t = text_of(text)[:tool_text]
|
|
95
|
+
if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
|
|
96
|
+
out.append({"name": str(name or "")[:80], "text": t})
|
|
97
|
+
|
|
98
|
+
messages = body.get("messages") if isinstance(body.get("messages"), list) else []
|
|
99
|
+
for m in messages:
|
|
100
|
+
if not isinstance(m, dict):
|
|
101
|
+
continue
|
|
102
|
+
for c in m.get("tool_calls") or []:
|
|
103
|
+
if isinstance(c, dict) and c.get("id") and isinstance(c.get("function"), dict):
|
|
104
|
+
names[c["id"]] = c["function"].get("name")
|
|
105
|
+
if isinstance(m.get("content"), list):
|
|
106
|
+
for c in m["content"]:
|
|
107
|
+
if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("id"):
|
|
108
|
+
names[c["id"]] = c.get("name")
|
|
109
|
+
for m in messages:
|
|
110
|
+
if not isinstance(m, dict):
|
|
111
|
+
continue
|
|
112
|
+
if m.get("role") in ("tool", "function"):
|
|
113
|
+
add(m.get("name") or names.get(m.get("tool_call_id")), m.get("content"))
|
|
114
|
+
if isinstance(m.get("content"), list):
|
|
115
|
+
for c in m["content"]:
|
|
116
|
+
if isinstance(c, dict) and c.get("type") == "tool_result":
|
|
117
|
+
add(names.get(c.get("tool_use_id")), c.get("content"))
|
|
118
|
+
items = body.get("input") if isinstance(body.get("input"), list) else []
|
|
119
|
+
for it in items:
|
|
120
|
+
if isinstance(it, dict) and it.get("type") == "function_call" and it.get("call_id"):
|
|
121
|
+
names[it["call_id"]] = it.get("name")
|
|
122
|
+
for it in items:
|
|
123
|
+
if isinstance(it, dict) and it.get("type") == "function_call_output":
|
|
124
|
+
add(names.get(it.get("call_id")), it.get("output"))
|
|
125
|
+
for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
|
|
126
|
+
for p in c.get("parts") if isinstance(c, dict) and isinstance(c.get("parts"), list) else []:
|
|
127
|
+
if isinstance(p, dict) and isinstance(p.get("functionResponse"), dict):
|
|
128
|
+
add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
|
|
129
|
+
return out or None
|
|
130
|
+
|
|
131
|
+
# A framework that folds its tools' answers into the next prompt as plain text sends no tool
|
|
132
|
+
# message to find: within one turn, what a later call's prompt carries that the call before did
|
|
133
|
+
# not, outside the model's own words, is that material. The first call holds the customer's
|
|
134
|
+
# message and the system prompt, present in every later call, so neither is ever counted.
|
|
135
|
+
seen_by_turn = {}
|
|
136
|
+
|
|
137
|
+
def texts_of(body):
|
|
138
|
+
out = set()
|
|
139
|
+
|
|
140
|
+
def take(v):
|
|
141
|
+
t = text_of(v).strip()
|
|
142
|
+
if t:
|
|
143
|
+
out.add(t[:tool_text])
|
|
144
|
+
for m in body.get("messages") if isinstance(body.get("messages"), list) else []:
|
|
145
|
+
if isinstance(m, dict) and m.get("role") != "assistant":
|
|
146
|
+
take(m.get("content"))
|
|
147
|
+
for it in body.get("input") if isinstance(body.get("input"), list) else []:
|
|
148
|
+
if isinstance(it, dict) and it.get("role") != "assistant" and it.get("type") != "function_call":
|
|
149
|
+
take(it.get("content") if it.get("content") is not None else it.get("output"))
|
|
150
|
+
if isinstance(body.get("system"), str):
|
|
151
|
+
take(body["system"])
|
|
152
|
+
for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
|
|
153
|
+
if isinstance(c, dict) and c.get("role") != "model":
|
|
154
|
+
for p in c.get("parts") if isinstance(c.get("parts"), list) else []:
|
|
155
|
+
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
|
156
|
+
take(p["text"])
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
def material_in(sent, turn):
|
|
160
|
+
if not turn:
|
|
161
|
+
return None
|
|
162
|
+
body = parsed(sent) if isinstance(sent, str) else None
|
|
163
|
+
if not isinstance(body, dict):
|
|
164
|
+
return None
|
|
165
|
+
now = texts_of(body)
|
|
166
|
+
before = seen_by_turn.get(turn)
|
|
167
|
+
seen_by_turn[turn] = now
|
|
168
|
+
if len(seen_by_turn) > 200:
|
|
169
|
+
seen_by_turn.pop(next(iter(seen_by_turn)))
|
|
170
|
+
if before is None:
|
|
171
|
+
return None
|
|
172
|
+
fresh = [{"name": "", "text": t} for t in now if t not in before][:tools_max]
|
|
173
|
+
return fresh or None
|
|
174
|
+
|
|
175
|
+
def rules_in(sent):
|
|
176
|
+
found = rules_now()
|
|
177
|
+
if found is None:
|
|
178
|
+
return None
|
|
179
|
+
body = norm(sent)
|
|
180
|
+
return [rid for rid, parts in found if all(p in body for p in parts)]
|
|
181
|
+
|
|
28
182
|
def write(row):
|
|
29
183
|
try:
|
|
30
184
|
fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
@@ -33,6 +187,17 @@ def _install():
|
|
|
33
187
|
except OSError:
|
|
34
188
|
pass
|
|
35
189
|
|
|
190
|
+
# Every other outbound call: the host and what it answered, never a byte of it. A search
|
|
191
|
+
# provider over its limit for a whole run was invisible until this row existed.
|
|
192
|
+
def dep(url, status, code=None):
|
|
193
|
+
try:
|
|
194
|
+
host = (urlsplit(str(url)).hostname or "").lower()
|
|
195
|
+
if not host or host in ("localhost", "127.0.0.1", "::1"):
|
|
196
|
+
return
|
|
197
|
+
turn = turn_now()
|
|
198
|
+
write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {})}})
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
36
201
|
def is_model_call(url):
|
|
37
202
|
try:
|
|
38
203
|
parts = urlsplit(str(url))
|
|
@@ -141,19 +306,31 @@ def _install():
|
|
|
141
306
|
except Exception:
|
|
142
307
|
return ""
|
|
143
308
|
|
|
144
|
-
def meter(url, sent, status, kind, raw):
|
|
309
|
+
def meter(url, sent, status, kind, raw, turn=None):
|
|
145
310
|
try:
|
|
146
311
|
tokens, model = read_reply(kind, (raw or "")[:reply_max])
|
|
147
312
|
parts = urlsplit(str(url))
|
|
148
313
|
row = {"at": int(time.time() * 1000), "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
|
|
149
314
|
"status": int(status or 0), "usage": tokens is not None}
|
|
150
315
|
row.update(tokens or {"promptTokens": 0, "cachedTokens": 0, "completionTokens": 0})
|
|
316
|
+
turn = turn or turn_now()
|
|
317
|
+
if turn:
|
|
318
|
+
row["turn"] = turn
|
|
319
|
+
as_text = sent if isinstance(sent, str) else text(sent)
|
|
320
|
+
found = rules_in(as_text)
|
|
321
|
+
if found is not None:
|
|
322
|
+
row["rules"] = found
|
|
323
|
+
tools = tools_in(as_text) or material_in(as_text, turn)
|
|
324
|
+
if tools:
|
|
325
|
+
row["tools"] = tools
|
|
151
326
|
write({"call": row})
|
|
152
327
|
except Exception:
|
|
153
328
|
pass
|
|
154
329
|
|
|
155
330
|
def started(method, path, headers):
|
|
156
|
-
|
|
331
|
+
turn = headers.pop("x-cortad-turn", None)
|
|
332
|
+
return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False,
|
|
333
|
+
"turn": turn if isinstance(turn, str) and turn_ok.match(turn) else None}
|
|
157
334
|
|
|
158
335
|
def keep(req, chunk):
|
|
159
336
|
if chunk and req["size"] < limit:
|
|
@@ -249,16 +426,154 @@ def _install():
|
|
|
249
426
|
wrapped._brainsless = True
|
|
250
427
|
return wrapped
|
|
251
428
|
|
|
429
|
+
# The app's own route table, read off the app object the server was handed, so the run knows
|
|
430
|
+
# every door the app has before a message is sent. Read only: nothing here changes the app.
|
|
431
|
+
route_max, openapi_max = 400, 8 * 1024 * 1024
|
|
432
|
+
verbs = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
433
|
+
served_port = [None]
|
|
434
|
+
|
|
435
|
+
def template(path):
|
|
436
|
+
# One syntax for every framework's path parameters: OpenAPI's {name}.
|
|
437
|
+
p = re.sub(r"\(\?P<(\w+)>(?:[^()]|\([^()]*\))*\)", r"{\1}", str(path))
|
|
438
|
+
p = re.sub(r"<(?:[^<>:]+:)?(\w+)>", r"{\1}", p)
|
|
439
|
+
p = re.sub(r"\{(\w+):[^{}]*\}", r"{\1}", p)
|
|
440
|
+
p = re.sub(r"(?:\$|\\Z)$", "", re.sub(r"^\^", "", p))
|
|
441
|
+
return p.replace("\\.", ".").replace("/?", "/")
|
|
442
|
+
|
|
443
|
+
def source_of(fn):
|
|
444
|
+
import inspect
|
|
445
|
+
try:
|
|
446
|
+
fn = inspect.unwrap(fn)
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
code = getattr(fn, "__code__", None)
|
|
450
|
+
path = code.co_filename if code else getattr(sys.modules.get(getattr(fn, "__module__", None) or ""), "__file__", None) or ""
|
|
451
|
+
rel = os.path.relpath(path, os.getcwd()) if path else ""
|
|
452
|
+
theirs = rel and not rel.startswith("..") and "site-packages" not in rel and "dist-packages" not in rel
|
|
453
|
+
return (rel if theirs else ""), str(getattr(fn, "__name__", "") or "")
|
|
454
|
+
|
|
455
|
+
def add(out, methods, path, fn):
|
|
456
|
+
# Methods unknown: the two a door is asked with.
|
|
457
|
+
file, handler = source_of(fn) if fn is not None else ("", "")
|
|
458
|
+
for m in ("POST", "GET") if methods is None else methods:
|
|
459
|
+
m = str(m).upper()
|
|
460
|
+
if m not in ("HEAD", "OPTIONS") and len(out) < route_max:
|
|
461
|
+
out.append({"method": m, "path": ("/" + template(path).lstrip("/"))[:1024], "file": file[:512], "handler": handler[:200]})
|
|
462
|
+
|
|
463
|
+
def class_methods(cls):
|
|
464
|
+
return [m for m in verbs if callable(getattr(cls, m.lower(), None))]
|
|
465
|
+
|
|
466
|
+
def starlette_routes(routes, prefix, out):
|
|
467
|
+
for r in routes or []:
|
|
468
|
+
path = prefix + (getattr(r, "path", "") or "")
|
|
469
|
+
sub = getattr(r, "routes", None)
|
|
470
|
+
# FastAPI 0.14x keeps an included router whole and resolves its routes on demand.
|
|
471
|
+
if callable(getattr(r, "effective_route_contexts", None)):
|
|
472
|
+
for c in r.effective_route_contexts():
|
|
473
|
+
if c.starlette_route is not None:
|
|
474
|
+
starlette_routes([c.starlette_route], prefix, out)
|
|
475
|
+
else:
|
|
476
|
+
add(out, c.methods, prefix + c.path, c.endpoint)
|
|
477
|
+
elif sub is not None:
|
|
478
|
+
starlette_routes(sub, path, out)
|
|
479
|
+
elif getattr(r, "endpoint", None) is not None and "WebSocket" not in type(r).__name__:
|
|
480
|
+
fn = r.endpoint
|
|
481
|
+
add(out, getattr(r, "methods", None) or (class_methods(fn) if isinstance(fn, type) else None), path, fn)
|
|
482
|
+
|
|
483
|
+
def flask_routes(app, out):
|
|
484
|
+
for rule in app.url_map.iter_rules():
|
|
485
|
+
if rule.endpoint != "static" and not rule.endpoint.endswith(".static"):
|
|
486
|
+
add(out, rule.methods, rule.rule, app.view_functions.get(rule.endpoint))
|
|
487
|
+
|
|
488
|
+
def django_routes(_app, out):
|
|
489
|
+
from django.urls import URLResolver, get_resolver
|
|
490
|
+
|
|
491
|
+
def walk(patterns, prefix):
|
|
492
|
+
for p in patterns:
|
|
493
|
+
path = prefix + template(str(p.pattern))
|
|
494
|
+
if isinstance(p, URLResolver):
|
|
495
|
+
walk(p.url_patterns, path)
|
|
496
|
+
continue
|
|
497
|
+
cb = p.callback
|
|
498
|
+
actions = getattr(cb, "actions", None)
|
|
499
|
+
cls = getattr(cb, "cls", None) or getattr(cb, "view_class", None)
|
|
500
|
+
add(out, list(actions) if isinstance(actions, dict) else class_methods(cls) if cls else None, path, cls or cb)
|
|
501
|
+
|
|
502
|
+
walk(get_resolver().url_patterns, "/")
|
|
503
|
+
|
|
504
|
+
def litestar_routes(app, out):
|
|
505
|
+
for r in app.routes:
|
|
506
|
+
for h in getattr(r, "route_handlers", None) or []:
|
|
507
|
+
methods = [m for m in getattr(h, "http_methods", ()) if str(m).upper() in verbs]
|
|
508
|
+
if methods:
|
|
509
|
+
add(out, methods, r.path, getattr(h, "fn", None))
|
|
510
|
+
|
|
511
|
+
def aiohttp_routes(app, out):
|
|
512
|
+
for r in app.router.routes():
|
|
513
|
+
add(out, None if r.method == "*" else [r.method], r.resource.canonical if r.resource else "", r.handler)
|
|
514
|
+
|
|
515
|
+
def asgi_routes(app, out):
|
|
516
|
+
starlette_routes(app.routes, "", out)
|
|
517
|
+
|
|
518
|
+
walkers = (("fastapi", "routes", asgi_routes), ("starlette", "routes", asgi_routes),
|
|
519
|
+
("quart", "url_map", flask_routes), ("flask", "url_map", flask_routes),
|
|
520
|
+
("litestar", "routes", litestar_routes), ("aiohttp", "router", aiohttp_routes),
|
|
521
|
+
("django", None, django_routes))
|
|
522
|
+
|
|
523
|
+
def app_in(obj):
|
|
524
|
+
# Servers hand the app over inside their own middleware, and each layer keeps the next.
|
|
525
|
+
for _ in range(12):
|
|
526
|
+
if obj is None:
|
|
527
|
+
break
|
|
528
|
+
tops = {c.__module__.split(".")[0] for c in type(obj).__mro__}
|
|
529
|
+
for name, attr, walk in walkers:
|
|
530
|
+
if name in tops and (attr is None or hasattr(obj, attr)):
|
|
531
|
+
return name, obj, walk
|
|
532
|
+
inner = getattr(obj, "app", None)
|
|
533
|
+
obj = inner if inner is not None else getattr(obj, "application", None)
|
|
534
|
+
return None, None, None
|
|
535
|
+
|
|
536
|
+
def openapi_of(name, app):
|
|
537
|
+
try:
|
|
538
|
+
if name == "fastapi":
|
|
539
|
+
# openapi() caches what it builds; the app's own cache is put back as it was.
|
|
540
|
+
kept = app.openapi_schema
|
|
541
|
+
try:
|
|
542
|
+
doc = app.openapi()
|
|
543
|
+
finally:
|
|
544
|
+
app.openapi_schema = kept
|
|
545
|
+
elif name == "litestar":
|
|
546
|
+
doc = app.openapi_schema.to_schema()
|
|
547
|
+
else:
|
|
548
|
+
return None
|
|
549
|
+
return doc if isinstance(doc, dict) and len(json.dumps(doc)) <= openapi_max else None
|
|
550
|
+
except Exception:
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
def publish_routes(app, port):
|
|
554
|
+
name, found, out = None, None, []
|
|
555
|
+
try:
|
|
556
|
+
name, found, walk = app_in(app)
|
|
557
|
+
if walk:
|
|
558
|
+
walk(found, out)
|
|
559
|
+
except Exception:
|
|
560
|
+
pass
|
|
561
|
+
port = port if isinstance(port, int) and not isinstance(port, bool) else None
|
|
562
|
+
write({"routes": {"framework": name or "unknown", "port": port, "routes": out, "openapi": openapi_of(name, found)}})
|
|
563
|
+
|
|
252
564
|
# What is patched, once the module it lives in has been imported by the app itself.
|
|
253
565
|
def patch_uvicorn(module):
|
|
254
566
|
load = module.Config.load
|
|
255
567
|
|
|
256
568
|
def loaded(self, *a, **k):
|
|
257
569
|
out = load(self, *a, **k)
|
|
570
|
+
# Under gunicorn, uvicorn's own port is its default and the socket is gunicorn's.
|
|
571
|
+
port = served_port[0] or getattr(self, "port", None)
|
|
572
|
+
publish_routes(self.loaded_app, port)
|
|
258
573
|
if not isinstance(self.loaded_app, Asgi):
|
|
259
574
|
self.loaded_app = Asgi(self.loaded_app)
|
|
260
|
-
if isinstance(
|
|
261
|
-
write({"listen":
|
|
575
|
+
if isinstance(port, int):
|
|
576
|
+
write({"listen": port, "pid": os.getpid()})
|
|
262
577
|
return out
|
|
263
578
|
|
|
264
579
|
module.Config.load = loaded
|
|
@@ -269,6 +584,7 @@ def _install():
|
|
|
269
584
|
def run(hostname, port, application, *a, **k):
|
|
270
585
|
if isinstance(port, int):
|
|
271
586
|
write({"listen": port, "pid": os.getpid()})
|
|
587
|
+
publish_routes(application, port)
|
|
272
588
|
return run_simple(hostname, port, wsgi(application), *a, **k)
|
|
273
589
|
|
|
274
590
|
module.run_simple = run
|
|
@@ -281,6 +597,54 @@ def _install():
|
|
|
281
597
|
|
|
282
598
|
module.WSGIHandler.__call__ = called
|
|
283
599
|
|
|
600
|
+
def patch_django_server(module):
|
|
601
|
+
run = module.run
|
|
602
|
+
|
|
603
|
+
def running(addr, port, wsgi_handler, *a, **k):
|
|
604
|
+
publish_routes(wsgi_handler, port)
|
|
605
|
+
return run(addr, port, wsgi_handler, *a, **k)
|
|
606
|
+
|
|
607
|
+
module.run = running
|
|
608
|
+
|
|
609
|
+
def patch_gunicorn(module):
|
|
610
|
+
load = module.Worker.load_wsgi
|
|
611
|
+
|
|
612
|
+
def loaded(self):
|
|
613
|
+
out = load(self)
|
|
614
|
+
try:
|
|
615
|
+
bound = [s.getsockname() for s in self.sockets]
|
|
616
|
+
served_port[0] = next((at[1] for at in bound if isinstance(at, tuple)), None)
|
|
617
|
+
except Exception:
|
|
618
|
+
pass
|
|
619
|
+
publish_routes(self.wsgi, served_port[0])
|
|
620
|
+
return out
|
|
621
|
+
|
|
622
|
+
module.Worker.load_wsgi = loaded
|
|
623
|
+
|
|
624
|
+
def patch_hypercorn(module):
|
|
625
|
+
serve = module.worker_serve
|
|
626
|
+
|
|
627
|
+
def serving(app, config, *a, **k):
|
|
628
|
+
port = None
|
|
629
|
+
try:
|
|
630
|
+
port = int(str(config.bind[0]).rsplit(":", 1)[1])
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
publish_routes(app, port)
|
|
634
|
+
return serve(app, config, *a, **k)
|
|
635
|
+
|
|
636
|
+
module.worker_serve = serving
|
|
637
|
+
|
|
638
|
+
def patch_aiohttp_web(module):
|
|
639
|
+
run_app = module.run_app
|
|
640
|
+
|
|
641
|
+
def run(app, *a, **k):
|
|
642
|
+
if isinstance(app, module.Application):
|
|
643
|
+
publish_routes(app, k.get("port") or 8080)
|
|
644
|
+
return run_app(app, *a, **k)
|
|
645
|
+
|
|
646
|
+
module.run_app = run
|
|
647
|
+
|
|
284
648
|
def sent_of(request):
|
|
285
649
|
try:
|
|
286
650
|
return text(request.content)
|
|
@@ -330,6 +694,7 @@ def _install():
|
|
|
330
694
|
|
|
331
695
|
def watch(request, response, sent):
|
|
332
696
|
if not is_model_call(request.url):
|
|
697
|
+
dep(request.url, response.status_code)
|
|
333
698
|
return response
|
|
334
699
|
kind = response.headers.get("content-type", "")
|
|
335
700
|
if getattr(response, "_content", None) is not None:
|
|
@@ -356,9 +721,11 @@ def _install():
|
|
|
356
721
|
pass
|
|
357
722
|
try:
|
|
358
723
|
response = _send(self, request, *a, **k)
|
|
359
|
-
except Exception:
|
|
724
|
+
except Exception as err:
|
|
360
725
|
if is_model_call(request.url):
|
|
361
726
|
meter(request.url, sent, 0, "", "")
|
|
727
|
+
else:
|
|
728
|
+
dep(request.url, 0, type(err).__name__)
|
|
362
729
|
raise
|
|
363
730
|
try:
|
|
364
731
|
return watch(request, response, sent)
|
|
@@ -374,9 +741,11 @@ def _install():
|
|
|
374
741
|
pass
|
|
375
742
|
try:
|
|
376
743
|
response = await _send(self, request, *a, **k)
|
|
377
|
-
except Exception:
|
|
744
|
+
except Exception as err:
|
|
378
745
|
if is_model_call(request.url):
|
|
379
746
|
meter(request.url, sent, 0, "", "")
|
|
747
|
+
else:
|
|
748
|
+
dep(request.url, 0, type(err).__name__)
|
|
380
749
|
raise
|
|
381
750
|
try:
|
|
382
751
|
return watch(request, response, sent)
|
|
@@ -392,8 +761,15 @@ def _install():
|
|
|
392
761
|
note(request.url, request.body)
|
|
393
762
|
except Exception:
|
|
394
763
|
pass
|
|
395
|
-
response = send(self, request, *a, **k)
|
|
396
764
|
try:
|
|
765
|
+
response = send(self, request, *a, **k)
|
|
766
|
+
except Exception as err:
|
|
767
|
+
if not is_model_call(request.url):
|
|
768
|
+
dep(request.url, 0, type(err).__name__)
|
|
769
|
+
raise
|
|
770
|
+
try:
|
|
771
|
+
if not is_model_call(request.url):
|
|
772
|
+
dep(request.url, response.status_code)
|
|
397
773
|
if is_model_call(request.url):
|
|
398
774
|
# A streamed reply is counted as a call whose counts were not read.
|
|
399
775
|
raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
|
|
@@ -413,9 +789,16 @@ def _install():
|
|
|
413
789
|
note(str_or_url, body)
|
|
414
790
|
except Exception:
|
|
415
791
|
pass
|
|
416
|
-
|
|
792
|
+
try:
|
|
793
|
+
response = await request(self, method, str_or_url, *a, **k)
|
|
794
|
+
except Exception as err:
|
|
795
|
+
if not is_model_call(str_or_url):
|
|
796
|
+
dep(str_or_url, 0, type(err).__name__)
|
|
797
|
+
raise
|
|
417
798
|
if is_model_call(str_or_url):
|
|
418
|
-
response._cortad = (str_or_url, text(body))
|
|
799
|
+
response._cortad = (str_or_url, text(body), turn_now())
|
|
800
|
+
else:
|
|
801
|
+
dep(str_or_url, response.status)
|
|
419
802
|
return response
|
|
420
803
|
|
|
421
804
|
# Metered when your app reads the reply, or, for one it streams, when the reply is let go.
|
|
@@ -426,14 +809,14 @@ def _install():
|
|
|
426
809
|
call = getattr(self, "_cortad", None)
|
|
427
810
|
if call:
|
|
428
811
|
self._cortad = None
|
|
429
|
-
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"))
|
|
812
|
+
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2])
|
|
430
813
|
return raw
|
|
431
814
|
|
|
432
815
|
def release_kept(self, *a, **k):
|
|
433
816
|
call = getattr(self, "_cortad", None)
|
|
434
817
|
if call:
|
|
435
818
|
self._cortad = None
|
|
436
|
-
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "")
|
|
819
|
+
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2])
|
|
437
820
|
return release(self, *a, **k)
|
|
438
821
|
|
|
439
822
|
module.ClientSession._request = requested
|
|
@@ -441,6 +824,8 @@ def _install():
|
|
|
441
824
|
module.ClientResponse.release = release_kept
|
|
442
825
|
|
|
443
826
|
exact = {"uvicorn.config": patch_uvicorn, "werkzeug.serving": patch_werkzeug, "django.core.handlers.wsgi": patch_django,
|
|
827
|
+
"django.core.servers.basehttp": patch_django_server, "gunicorn.workers.base": patch_gunicorn,
|
|
828
|
+
"hypercorn.asyncio.run": patch_hypercorn, "hypercorn.trio.run": patch_hypercorn, "aiohttp.web": patch_aiohttp_web,
|
|
444
829
|
"requests.sessions": patch_requests, "aiohttp.client": patch_aiohttp}
|
|
445
830
|
# By family, not by name: the OpenAI SDK moved to a renamed copy of httpx (httpx2), and a patch
|
|
446
831
|
# keyed on "httpx" alone saw none of its calls.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { at, clip, has, num, pack, pageOf, plural, SIDE } from "./words.mjs";
|
|
2
|
+
|
|
3
|
+
// What Cortad read of the repository: the walkthrough `status` carries, and one section in full
|
|
4
|
+
// for `status` with show. Both use the same lines, so the full list reads like the walkthrough.
|
|
5
|
+
//
|
|
6
|
+
// The full list comes back from GET /mcp/status?show=<name>: read.rules.all, read.machine.misses
|
|
7
|
+
// complete with read.machine.mets, read.journeys.all, read.doors.all, read.trials.all. Where the API
|
|
8
|
+
// has not sent it, what it did send is listed with "n of N listed".
|
|
9
|
+
export const SHOW = ["rules", "standards", "journeys", "endpoints", "trials"];
|
|
10
|
+
|
|
11
|
+
const rulesHead = (rules) => {
|
|
12
|
+
const files = Array.isArray(rules.files) ? rules.files.length : rules.files;
|
|
13
|
+
return `Rules in the code: ${num(rules.count)}${has(files) ? `, in ${plural(files, "file")}` : ""}.`;
|
|
14
|
+
};
|
|
15
|
+
const ruleLine = (e) => ` ${at(e.path, e.line)} "${clip(e.text, 300)}"`;
|
|
16
|
+
const headOf = (label, parts) => (parts.some(Boolean) ? `${label}: ${parts.filter(Boolean).join(", ")}.` : `${label}:`);
|
|
17
|
+
const standardsHead = (m) => headOf("Engineering standards", [has(m.decided) && `${num(m.decided)} decided`, has(m.met) && `${num(m.met)} met`, has(m.missed) && `${num(m.missed)} missed`]);
|
|
18
|
+
const standardLine = (x) => ` ${x.path ? `${at(x.path, x.line)} ` : ""}${x.standard}${x.detail ? `: ${clip(x.detail, 240)}` : ""}${x.decidedBy ? ` (decided by ${x.decidedBy === "code" ? "code" : "a model"})` : ""}`;
|
|
19
|
+
const trialsHead = (t) => headOf("Trials", [has(t.written) && `${num(t.written)} written`, has(t.playable) && `${num(t.playable)} playable`]);
|
|
20
|
+
const heldWhy = (h) => `${SIDE[h.side] ? `, ${SIDE[h.side]}` : ""}${h.why ? `: ${clip(h.why, 300)}` : ""}`;
|
|
21
|
+
const named = (label, group, max = 12) => {
|
|
22
|
+
if (!group) return null;
|
|
23
|
+
const names = group.names ?? [];
|
|
24
|
+
const more = (group.count ?? names.length) - Math.min(names.length, max);
|
|
25
|
+
const list = names.slice(0, max).join(", ");
|
|
26
|
+
return `${label} (${num(group.count ?? names.length)})${list ? `: ${list}${more > 0 ? `, and ${more} more` : ""}` : ""}.`;
|
|
27
|
+
};
|
|
28
|
+
// "Journeys (6):" when every one is here, "Journeys (6), 4 listed:" when not.
|
|
29
|
+
const counted = (label, count, n) => `${label} (${num(count ?? n)})${has(count) && n < count ? `, ${num(n)} listed` : ""}:`;
|
|
30
|
+
|
|
31
|
+
// The walkthrough, in the order the person is walked through it.
|
|
32
|
+
export function readLines(r) {
|
|
33
|
+
const lines = ["What Cortad read:"];
|
|
34
|
+
if (r.rules) {
|
|
35
|
+
const examples = (r.rules.examples ?? []).slice(0, 3);
|
|
36
|
+
lines.push(`${rulesHead(r.rules)}${examples.length ? ` ${examples.length} of them:` : ""}`, ...examples.map(ruleLine));
|
|
37
|
+
}
|
|
38
|
+
for (const line of [named("Journeys", r.journeys), named("Simulated users", r.profiles), named("Endpoints", r.doors)]) if (line) lines.push(line);
|
|
39
|
+
const m = r.machine;
|
|
40
|
+
if (m) {
|
|
41
|
+
const misses = m.misses ?? [];
|
|
42
|
+
lines.push(standardsHead(m), ...misses.slice(0, 12).map(standardLine));
|
|
43
|
+
if (misses.length > 12) lines.push(` and ${misses.length - 12} more.`);
|
|
44
|
+
}
|
|
45
|
+
const q = r.questions;
|
|
46
|
+
if (q) lines.push(`Questions: ${num(q.total)}${has(q.everywhere) ? `; ${num(q.everywhere)} asked in every conversation` : ""}${has(q.placed) ? `, ${num(q.placed)} placed in the situations they fit` : ""}.`);
|
|
47
|
+
const t = r.trials;
|
|
48
|
+
if (t) lines.push(trialsHead(t), ...(t.held ?? []).map((h) => ` ${h.surface}: ${plural(h.n, "trial")} held back${heldWhy(h)}`));
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function showText(d, show, page = 1) {
|
|
53
|
+
if (!d.read) return "Cortad is still reading this repository.";
|
|
54
|
+
const { head, lines, continued } = sectionOf(d.read, show);
|
|
55
|
+
return pageOf(pack(head, lines, continued), page, (n) => `status with show ${show} and page ${n}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sectionOf(r, show) {
|
|
59
|
+
switch (show) {
|
|
60
|
+
case "rules": {
|
|
61
|
+
const rules = r.rules ?? { count: 0 };
|
|
62
|
+
const all = rules.all ?? rules.examples ?? [];
|
|
63
|
+
return { head: `${rulesHead(rules)}${all.length < rules.count ? ` ${num(all.length)} of ${num(rules.count)} listed.` : ""}`, lines: all.map(ruleLine), continued: "Rules in the code, continued." };
|
|
64
|
+
}
|
|
65
|
+
case "standards": {
|
|
66
|
+
const m = r.machine ?? {};
|
|
67
|
+
const misses = m.misses ?? [];
|
|
68
|
+
const mets = m.mets ?? [];
|
|
69
|
+
return {
|
|
70
|
+
head: standardsHead(m),
|
|
71
|
+
lines: [misses.length && counted("Missed", m.missed, misses.length), ...misses.map(standardLine), mets.length && counted("Met", m.met, mets.length), ...mets.map(standardLine)].filter(Boolean),
|
|
72
|
+
continued: "Engineering standards, continued.",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
case "journeys": {
|
|
76
|
+
const all = r.journeys?.all ?? (r.journeys?.names ?? []).map((name) => ({ name }));
|
|
77
|
+
return { head: counted("Journeys", r.journeys?.count, all.length), lines: all.map((j) => ` ${j.name}${j.steps?.length ? `: ${clip(j.steps.join("; "), 600)}` : ""}`), continued: "Journeys, continued." };
|
|
78
|
+
}
|
|
79
|
+
case "endpoints": {
|
|
80
|
+
const all = r.doors?.all ?? (r.doors?.names ?? []).map((name) => ({ name }));
|
|
81
|
+
return { head: counted("Endpoints", r.doors?.count, all.length), lines: all.map((e) => ` ${e.name}${e.path ? `: ${[e.method, e.path].filter(Boolean).join(" ")}` : ""}`), continued: "Endpoints, continued." };
|
|
82
|
+
}
|
|
83
|
+
case "trials":
|
|
84
|
+
default: {
|
|
85
|
+
const t = r.trials ?? {};
|
|
86
|
+
const all = t.all ?? (t.held ?? []).map((h) => ({ surface: h.surface, held: h.n, why: h.why, side: h.side }));
|
|
87
|
+
const line = (x) => ` ${x.surface}: ${[has(x.written) && `${num(x.written)} written`, has(x.playable) && `${num(x.playable)} playable`, x.held && `${num(x.held)} held back`].filter(Boolean).join(", ")}${x.held ? heldWhy(x) : ""}`;
|
|
88
|
+
return { head: trialsHead(t), lines: all.map(line), continued: "Trials, continued." };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|