cortad 0.3.1-rc.9 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
@@ -1,8 +1,9 @@
1
1
  # Loaded into your Python app by the command that started it (through PYTHONPATH), and only then.
2
- # The same job as trace.cjs does for Node: it watches for a request to your app during which your
3
- # app called a model. That request is your AI's door, with the exact body it takes and the sign-in
4
- # it carried. What it sees is written to a file only you can read, in the command's own folder on
5
- # this machine. Nothing here talks to a network, and nothing here may ever stop your app starting.
2
+ # The same job as trace.cjs does for Node: it watches for requests to your app during which your app
3
+ # called a model. Each is an exchange: the exact body, the sign-in it carried, what your app answered
4
+ # and the model calls it made, so the door is proven by a message rather than guessed. What it sees
5
+ # is written to a file only you can read, in the command's own folder on this machine. Nothing here
6
+ # talks to a network, and nothing here may ever stop your app starting.
6
7
  # ponytail: shadows a sitecustomize of the project's own, which is rare; chain to it if one shows up.
7
8
  import os
8
9
 
@@ -10,6 +11,9 @@ _FILE = os.environ.get("CORTAD_TRACE_FILE")
10
11
  _RULES = os.environ.get("CORTAD_RULES_FILE")
11
12
  _WRITES = os.environ.get("CORTAD_WRITES_DIR")
12
13
  _APP_ROOT = os.environ.get("CORTAD_APP_ROOT")
14
+ _OUTBOUND = os.environ.get("CORTAD_OUTBOUND_FILE")
15
+ # The port this hook answers held writes on: its own server, never a store the app reached.
16
+ _held_ports = []
13
17
 
14
18
 
15
19
  # What the app writes into its own folder while a run is on: each file copied once before its first
@@ -24,7 +28,7 @@ def _install_writes():
24
28
  import shutil
25
29
 
26
30
  roots = {os.path.realpath(_APP_ROOT), os.path.abspath(_APP_ROOT)}
27
- not_data = re.compile(r"(?:^|/)(?:node_modules|\.git|\.next|\.cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|coverage|\.cortad)(?:/|$)|\.(?:log|pyc|tmp|swp)$")
31
+ not_data = re.compile(r"(?:^|/)(?:node_modules|\.git|\.next|\.cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|coverage|\.cortad|logs?)(?:/|$)|\.log(?:\.[\w-]+)?$|\.(?:pyc|tmp|swp)$")
28
32
  state = {"run": None, "at": -1, "seen": set()}
29
33
  real_open = builtins.open
30
34
  real_os_open = os.open
@@ -108,6 +112,7 @@ def _install_writes():
108
112
  def _install():
109
113
  import contextvars
110
114
  import importlib.abc
115
+ import itertools
111
116
  import importlib.util
112
117
  import json
113
118
  import re
@@ -117,17 +122,20 @@ def _install():
117
122
 
118
123
  ctx = contextvars.ContextVar("cortad_request", default=None)
119
124
  limit = 65536
120
- said = set()
121
125
  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)
122
- model_path = re.compile(r"/(?:chat/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|/invoke(?:-with-response-stream)?$|/api/(?:chat|generate)$", re.I)
126
+ model_path = re.compile(r"/(?:chat/completions|completions|responses|messages|embeddings|language-model|embedding-model)$|:(?:generateContent|streamGenerateContent)|/invoke(?:-with-response-stream)?$|/api/(?:chat|generate)$", re.I)
127
+ # wire.cjs MESSAGING_HOST: senders whose own path is the one a model API answers on.
128
+ messaging_host = re.compile(r"(?:^|\.)(?:nexmo\.com|vonage\.com|telnyx\.com|twilio\.com|mailgun\.net|sinch\.com|messagebird\.com|bird\.com)$", re.I)
123
129
 
124
130
  # The turn a message came in under, when the run tagged it (one opaque id per request), so a
125
131
  # model call and its prompt can be pinned to the reply they produced while turns overlap.
126
132
  turn_ok = re.compile(r"^[A-Za-z0-9:_.-]{1,80}$")
127
133
 
128
- def turn_now():
129
- req = ctx.get()
130
- return req.get("turn") if req else None
134
+ # The request a row was made inside: its exchange id, and the run's turn tag when it has one.
135
+ def inside_of(req):
136
+ if not req:
137
+ return {}
138
+ return {"ex": req["id"], **({"turn": req["turn"]} if req.get("turn") else {})}
131
139
 
132
140
  # The customer's own rule sentences, written beside the trace by the run, so each model call can
133
141
  # say which of them its prompt carried. Absent file, nothing is claimed. Reloaded on change.
@@ -165,7 +173,14 @@ def _install():
165
173
  # What the app's tools answered, as the prompt of the next model call carries them (chat tool
166
174
  # messages, responses function outputs, Anthropic tool_result blocks, Gemini functionResponse
167
175
  # parts): the material a reply's facts rest on. Bounded per call.
168
- tool_text, tools_max = 3000, 12
176
+ # A tool's answer is kept to the cap the reader takes, and one longer says it was cut (wire.cjs toolText).
177
+ tool_text, tools_max = 12000, 12
178
+
179
+ def tool_text_row(name, full, **extra):
180
+ out = {"name": str(name or "")[:80], "text": full[:tool_text], **extra}
181
+ if len(full) > tool_text:
182
+ out["cut"] = True
183
+ return out
169
184
 
170
185
  def text_of(v):
171
186
  if isinstance(v, str):
@@ -208,9 +223,10 @@ def _install():
208
223
  out, names = [], {}
209
224
 
210
225
  def add(name, text):
211
- t = text_of(text)[:tool_text]
226
+ full = text_of(text)
227
+ t = full[:tool_text]
212
228
  if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
213
- out.append({"name": str(name or "")[:80], "text": t})
229
+ out.append(tool_text_row(name, full))
214
230
 
215
231
  messages = items(body.get("messages"))
216
232
  for m in messages:
@@ -269,7 +285,54 @@ def _install():
269
285
  v = raw
270
286
  return json.dumps(clipped({} if v is None else v), ensure_ascii=False, separators=(",", ":"))[:args_max]
271
287
 
272
- def calls_of(events):
288
+ # Each function tool the request declares, in every provider's shape: its name, what its
289
+ # description says it does, and its JSON schema (None where it declares none).
290
+ def tool_defs(body):
291
+ out = []
292
+
293
+ def add(name, does, schema):
294
+ if isinstance(name, str) and name:
295
+ out.append({"name": name, "does": does if isinstance(does, str) else "", "schema": schema if isinstance(schema, dict) else None})
296
+
297
+ config = body.get("toolConfig") if isinstance(body.get("toolConfig"), dict) else {}
298
+ for t in items(body.get("tools")) + items(config.get("tools")):
299
+ if isinstance(t.get("function"), dict):
300
+ f = t["function"]
301
+ add(f.get("name"), f.get("description"), f.get("parameters"))
302
+ elif isinstance(t.get("toolSpec"), dict):
303
+ spec = t["toolSpec"]
304
+ add(spec.get("name"), spec.get("description"), spec["inputSchema"].get("json") if isinstance(spec.get("inputSchema"), dict) else None)
305
+ else:
306
+ add(t.get("name"), t.get("description"), t.get("parameters") or t.get("input_schema") or t.get("inputSchema"))
307
+ for d in items(t.get("functionDeclarations") or t.get("function_declarations")):
308
+ add(d.get("name"), d.get("description"), d.get("parameters"))
309
+ for f in items(body.get("functions")):
310
+ add(f.get("name"), f.get("description"), f.get("parameters"))
311
+ return out
312
+
313
+ def schemas_of(body):
314
+ return {d["name"]: d["schema"] for d in tool_defs(body) if d["schema"]}
315
+
316
+ # What a tool's declared schema refuses in the arguments the model sent, said plainly, or None.
317
+ # Read before any value is clipped: a clipped value is ours, never the model's.
318
+ def refused_by(schema, raw):
319
+ args = {} if raw is None else raw
320
+ if isinstance(args, str):
321
+ if not args.strip():
322
+ args = {}
323
+ else:
324
+ try:
325
+ args = json.loads(args)
326
+ except ValueError:
327
+ return "the arguments are not valid JSON"
328
+ if not isinstance(args, dict):
329
+ return "the arguments are not a JSON object"
330
+ required = schema.get("required") if isinstance(schema.get("required"), list) else []
331
+ missing = next((k for k in required if isinstance(k, str) and k not in args), None)
332
+ return 'the required input "%s" is missing' % missing if missing else None
333
+
334
+ # `schemas`: the request's declared tools, so each call the reply makes is checked against its own.
335
+ def calls_of(events, schemas=None):
273
336
  whole, parts, n = {}, {}, [0]
274
337
 
275
338
  def put(key, name, args):
@@ -332,7 +395,13 @@ def _install():
332
395
  if isinstance(p.get("functionCall"), dict):
333
396
  put(None, p["functionCall"].get("name"), p["functionCall"].get("args"))
334
397
  found = list(whole.values()) + [tuple(p) for p in parts.values()]
335
- return [{"name": str(name)[:80], "arguments": args_text(args)} for name, args in found if name]
398
+ out = []
399
+ for name, args in found:
400
+ if not name:
401
+ continue
402
+ refused = refused_by(schemas[name], args) if schemas and name in schemas else None
403
+ out.append(dict({"name": str(name)[:80], "arguments": args_text(args)}, **({"refused": refused} if refused else {})))
404
+ return out
336
405
 
337
406
  # This turn's earlier calls, as the prompt resends them after the person's latest message.
338
407
  def called_before(body):
@@ -372,19 +441,8 @@ def _install():
372
441
  if tool_name.fullmatch(v) and not final.fullmatch(v):
373
442
  out.add(v)
374
443
 
375
- def get(d, k):
376
- return d.get(k) if isinstance(d, dict) else None
377
-
378
- for t in items(body.get("tools")):
379
- add(t.get("name"))
380
- add(get(t.get("function"), "name"))
381
- add(get(t.get("toolSpec"), "name"))
382
- for d in items(t.get("functionDeclarations") or t.get("function_declarations")):
383
- add(d.get("name"))
384
- for f in items(body.get("functions")):
385
- add(f.get("name"))
386
- for t in items(get(body.get("toolConfig"), "tools")):
387
- add(get(t.get("toolSpec"), "name"))
444
+ for d in tool_defs(body):
445
+ add(d["name"])
388
446
  text = prompt_text(body)[:200000]
389
447
  for m in re.finditer(r"^[ \t]*Tool Name:[ \t]*([^\n]+)", text, re.I | re.M):
390
448
  add(m.group(1))
@@ -502,9 +560,11 @@ def _install():
502
560
  out.append((before[-1][1], (m.group(1) if m.group(1) is not None else m.group(3)).strip()))
503
561
  return out
504
562
 
505
- # The model's own words: the text it answered, whole or streamed, in every provider's shape.
563
+ # The model's own words: the text it answered, whole or streamed, in every provider's shape. A
564
+ # responses stream sends its words as deltas and then whole again in its closing event: the
565
+ # closing copy counts only where no delta came.
506
566
  def reply_text(events):
507
- s = []
567
+ s, closing, streamed = [], [], False
508
568
  for e in events:
509
569
  if not isinstance(e, dict):
510
570
  continue
@@ -517,10 +577,14 @@ def _install():
517
577
  s.append(c["text"])
518
578
  if e.get("type") == "response.output_text.delta" and isinstance(e.get("delta"), str):
519
579
  s.append(e["delta"])
580
+ streamed = True
520
581
  resp = e.get("response") if isinstance(e.get("response"), dict) else {}
521
- for it in items(e.get("output")) + items(resp.get("output")):
582
+ for it in items(e.get("output")):
522
583
  if it.get("type") == "message":
523
584
  s.extend(p["text"] for p in items(it.get("content")) if isinstance(p.get("text"), str))
585
+ for it in items(resp.get("output")):
586
+ if it.get("type") == "message":
587
+ closing.extend(p["text"] for p in items(it.get("content")) if isinstance(p.get("text"), str))
524
588
  delta = e.get("delta") if isinstance(e.get("delta"), dict) else {}
525
589
  if e.get("type") == "content_block_delta" and isinstance(delta.get("text"), str):
526
590
  s.append(delta["text"])
@@ -537,7 +601,7 @@ def _install():
537
601
  s.append(p["text"])
538
602
  if isinstance(e.get("response"), str):
539
603
  s.append(e["response"])
540
- return "".join(s)
604
+ return "".join(s if streamed else s + closing)
541
605
 
542
606
  # This turn as the prompt carries it: the model's earlier words after the person's latest
543
607
  # message, and everything from that message on, where a single-prompt agent keeps its scratchpad.
@@ -564,27 +628,96 @@ def _install():
564
628
  out += [body.get("prompt") if isinstance(body.get("prompt"), str) else "", inp if isinstance(inp, str) else ""]
565
629
  return "\x00\n".join(out)
566
630
 
567
- def called_in(sent, events):
631
+ def called_in(sent, events, provided=()):
568
632
  body = parsed(sent) if isinstance(sent, str) else None
569
633
  out = []
570
634
  declared = declared_in(body) if isinstance(body, dict) else set()
571
635
  written = [{"name": n, "arguments": args_text(a)} for _, n, a in written_calls(turn_texts(body, False, declared) if isinstance(body, dict) else "", declared) + written_calls(reply_text(events), declared)]
572
- for c in (called_before(body) if isinstance(body, dict) else []) + calls_of(events) + written:
636
+ for c in (called_before(body) if isinstance(body, dict) else []) + calls_of(events, schemas_of(body) if isinstance(body, dict) else None) + list(provided) + written:
573
637
  if len(out) < calls_max and not any(o["name"] == c["name"] and o["arguments"] == c["arguments"] for o in out):
574
638
  out.append(c)
575
639
  return out or None
576
640
 
577
- # The passages the prompt carried as retrieved context: a block the app itself labels as
578
- # context, documents, knowledge, sources or search results, in its system prompt or from the
579
- # person's latest message on, and Anthropic document and search_result blocks. The app's own
580
- # instructions are not passages: only what sits under such a label is.
641
+ # Tools the model's provider runs for the app (a web search, a file search, a code interpreter, a
642
+ # remote MCP server): the calls come back in the model's own reply, and so does what each answered
643
+ # where the provider says it. A call it marks failed, or an error it hands back, is the tool failing
644
+ # there, in the provider's own words. Answers pair with calls in the order the reply lists them.
645
+ provider_call = re.compile(r"^(web_search|file_search|code_interpreter|image_generation|mcp)_call$")
646
+ provider_result = re.compile(r"^\w+_tool_result$")
647
+
648
+ def provider_in(sent, events):
649
+ body = parsed(sent) if isinstance(sent, str) else None
650
+ offered = [t["type"] for t in items(body.get("tools") if isinstance(body, dict) else None) if isinstance(t.get("type"), str) and t["type"] not in ("function", "custom")]
651
+
652
+ def named(kind):
653
+ return next((t for t in offered if t.startswith(kind)), kind)
654
+
655
+ calls, names, answers = {}, {}, []
656
+
657
+ def answer(name, said):
658
+ full = text_of(said)
659
+ t = full[:tool_text]
660
+ if name and t.strip() and len(answers) < tools_max and not any(a["name"] == name and a["text"] == t for a in answers):
661
+ answers.append(tool_text_row(name, full, provider=True))
662
+
663
+ for e in events:
664
+ if not isinstance(e, dict):
665
+ continue
666
+ order = []
667
+ resp = e.get("response") if isinstance(e.get("response"), dict) else {}
668
+ for it in items(e.get("output")) + items([e.get("item")]) + items(resp.get("output")):
669
+ m = provider_call.match(str(it.get("type") or ""))
670
+ if m:
671
+ name = str(it.get("name") or "mcp") if m.group(1) == "mcp" else named(m.group(1))
672
+ order.append(name)
673
+ if it.get("id"):
674
+ given = it.get("action") if it.get("action") is not None else it.get("arguments")
675
+ calls[it["id"]] = {"name": name[:80], "arguments": args_text({} if given is None else given)}
676
+ if it.get("status") == "failed" or it.get("error"):
677
+ answer(name, it.get("error") or "%s %s" % (it.get("type"), it.get("status")))
678
+ elif it.get("type") == "tool_output":
679
+ answer(order.pop(0) if order else (offered[0] if len(offered) == 1 else ""), it.get("output"))
680
+ msg = e.get("message") if isinstance(e.get("message"), dict) else {}
681
+ for b in items(e.get("content")) + items(msg.get("content")) + items([e.get("content_block")]):
682
+ if b.get("type") in ("server_tool_use", "mcp_tool_use") and b.get("id"):
683
+ names[b["id"]] = b.get("name")
684
+ calls[b["id"]] = {"name": str(b.get("name"))[:80], "arguments": args_text({} if b.get("input") is None else b.get("input"))}
685
+ c = b.get("content")
686
+ if provider_result.match(str(b.get("type") or "")) and (b.get("is_error") is True or (isinstance(c, dict) and str(c.get("type") or "").endswith("_error"))):
687
+ answer(names.get(b.get("tool_use_id")) or re.sub(r"_tool_result$", "", b["type"]), c)
688
+ return list(calls.values()), answers
689
+
690
+ # What the prompt was handed besides its instructions, of two kinds. What the app retrieved: a
691
+ # block it labels as context, documents, knowledge, sources or search results. And its own
692
+ # record of the person, passed beside the ask: a profile, a résumé, an account, orders, a JSON
693
+ # or key: value block of their data, under any heading or none ("data"). A reply's fact taken
694
+ # from either was given, not made up: resumeforge put the saved profile into every prompt, and
695
+ # its replies' facts from it were read as invention. In the system prompt or from the person's
696
+ # latest message on, and Anthropic document and search_result blocks.
581
697
  passage_text, passages_max = 3000, 6
582
698
  material = re.compile(r"\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq|relevant)\b|检索|知识|参考资料|资料|上下文|文档|背景|相关", re.I)
699
+ record = re.compile(r"\b(?:profiles?|r[eé]sum[eé]s?|cv|(?<!into )accounts?|orders|purchases|records|(?:user|customer|member|patient|student|client|candidate)[ _]?(?:info\w*|data|details?|facts)|(?:order|purchase|account|medical|payment|employment|transaction|work) history)\b|个人资料|用户资料|个人信息|用户信息|简历|档案|订单|账户|账号|会员", re.I)
700
+ def name_of(label):
701
+ return re.sub(r"(?:开始|\s+(?:start|begin))$", "", re.sub(r"[#::\[\]=]", "", label).strip(), flags=re.I).strip()
702
+
703
+ # A record is named by a label that ends in its word ("User profile", "[已选个人资料开始]"): a
704
+ # heading of the instructions that only mentions one ("关于简历通本身与使用平台") is not it.
705
+ record_end = re.compile(r"(?:" + record.pattern + r")$", re.I)
706
+
707
+ def is_record(label):
708
+ return bool(record_end.search(name_of(label)))
709
+
710
+ def is_label(label):
711
+ return bool(material.search(label)) or is_record(label)
712
+
713
+ def kind_of(label):
714
+ return "data" if is_record(label) else None
715
+
583
716
  heading = re.compile(r"^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$")
584
- # An inline label is a noun phrase that ends in the material word ("context:", "background:",
585
- # "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
586
- inline = re.compile(r"^\s*(?:[-*]\s+)?([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff '’_-]{0,29})[::]\s*\S")
587
- named = re.compile(r"(?:" + material.pattern + r")\s*$", re.I)
717
+ # An inline label is a noun phrase that ends in the label word ("context:", "background:",
718
+ # "user profile:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
719
+ inline = re.compile(r"^\s*(?:[-*]\s+)?([A-Za-z一-鿿][A-Za-z一-鿿 '’_-]{0,29})[::]\s*\S")
720
+ named = re.compile(r"(?:" + material.pattern + "|" + record.pattern + r")\s*$", re.I)
588
721
  tagged = re.compile(r"<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)</\1>")
589
722
 
590
723
  # A label's block runs on to the next heading of its own kind: "资料:" over retrieved chunks
@@ -592,7 +725,7 @@ def _install():
592
725
  # one chunk of five and the reader called the knowledge base's own 1% fee made up. A markdown
593
726
  # label holds deeper headings. A closing paragraph that opens on a label of its own ("问题:...",
594
727
  # "Question: ...") is the prompt's ask, not material. A long block is several passages, cut
595
- # between its paragraphs.
728
+ # between its paragraphs, then its lines.
596
729
  markdown = re.compile(r"^\s*(#{1,6})\s")
597
730
 
598
731
  def ends_block(label, line):
@@ -606,21 +739,65 @@ def _install():
606
739
  return bool(m and not named.search(m.group(1)))
607
740
 
608
741
  # A heading inside a fenced block is the material's own text: a README chunk's "## Features".
609
- fence = re.compile(r"^\s*(?:```|~~~)", re.M)
742
+ fence = re.compile(r"^\s*(?:```|~~~)")
743
+
744
+ def fences(para):
745
+ return sum(1 for l in para.split("\n") if fence.match(l))
610
746
 
611
- def add_block(add, name, paras):
747
+ def pieces(p):
748
+ if len(p) <= passage_text:
749
+ return [p]
750
+ return [l[i:i + passage_text] for l in p.split("\n") for i in range(0, len(l), passage_text)]
751
+
752
+ def add_block(add, name, paras, kind=None):
612
753
  text = ""
613
- for p in paras:
614
- if text and len(text) + len(p) + 2 > passage_text:
615
- add(name, text)
616
- text = ""
617
- text += ("\n\n" if text else "") + p
618
- add(name, text)
754
+ for para in paras:
755
+ for j, p in enumerate(pieces(para)):
756
+ if text and len(text) + len(p) + 2 > passage_text:
757
+ add(name, text, kind)
758
+ text = ""
759
+ text += (("\n" if j else "\n\n") if text else "") + p
760
+ add(name, text, kind)
761
+
762
+ # The person's data with no label word: a JSON object, or key: value lines (YAML too) under a
763
+ # heading. A transcript pasted as "User: ... / Assistant: ..." is the conversation; a tool's
764
+ # schema, an agent's Thought/Action scaffold and an answer's shape are instructions.
765
+ kv_line = re.compile(r"^\s*(?:[-*]\s+)?[\"']?([^\W\d][\w .'’()/-]{0,39})[\"']?\s*[::]\s*(\S.*)?$")
766
+ role = re.compile(r"^(?:user|assistant|human|ai|system|bot|model|agent|customer|用户|助手|客服|顾客|系统)$", re.I)
767
+ scaffold = re.compile(r"^(?:tool\b.*|action(?: input)?|thought|observation|final answer|question|answer|input|output)$", re.I)
768
+ shape = re.compile(r"format|schema|example|output|respon|return|reply|格式|示例|输出|返回", re.I)
769
+ tool_schema = re.compile(r"\"(?:parameters|input_schema|inputSchema)\"\s*:")
770
+ # "[已选岗位结束]": the closing marker an app puts after a record is not part of it.
771
+ closer = re.compile(r"^\s*\[[^\]\n]{1,80}\]\s*$")
772
+
773
+ def data_in(para, before):
774
+ lines = para.split("\n")
775
+ head = lines[0] if heading.match(lines[0]) else None
776
+ body = [l for l in (lines[1:] if head else lines) if l.strip() and not fence.match(l)]
777
+ if len(body) > 1 and closer.match(body[-1]):
778
+ body.pop()
779
+ name = head or (before if before and "\n" not in before and heading.match(before) else "")
780
+ text = "\n".join(body)
781
+ if shape.search(name):
782
+ return None
783
+ if re.match(r"^\s*[{\[]", text):
784
+ try:
785
+ v = json.loads(text)
786
+ except ValueError:
787
+ return None
788
+ return (name_of(name) or "data", text) if isinstance(v, (dict, list)) and v and not tool_schema.search(text) else None
789
+ kv = [kv_line.match(l) for l in body]
790
+ valued = [m.group(1).strip() for m in kv if m and m.group(2)]
791
+ shaped = bool(name) and all(m or re.match(r"^\s+\S|^\s*-\s", l) for m, l in zip(kv, body))
792
+ if shaped and len(valued) >= 2 and sum(1 for k in valued if role.match(k)) < 2 and not any(scaffold.match(k) for k in valued):
793
+ return (name_of(name), text)
794
+ return None
619
795
 
620
796
  def labelled(text, add):
621
797
  def lift(m):
622
- if material.search(m.group(1).replace("_", " ")):
623
- add(m.group(1), m.group(2))
798
+ label = m.group(1).replace("_", " ")
799
+ if is_label(label):
800
+ add(m.group(1), m.group(2), kind_of(label))
624
801
  return ""
625
802
  return m.group(0)
626
803
  paras = re.split(r"\n\s*\n", tagged.sub(lift, str(text or "")))
@@ -628,19 +805,26 @@ def _install():
628
805
  while i < len(paras):
629
806
  lines = paras[i].split("\n")
630
807
  head = lines[0]
631
- if heading.match(head) and material.search(head):
808
+ if heading.match(head) and is_label(head):
632
809
  block = ["\n".join(lines[1:])]
633
- fenced = len(fence.findall(block[0])) % 2 == 1
810
+ fenced = fences(block[0]) % 2 == 1
634
811
  while i + 1 < len(paras) and (fenced or (not ends_block(head, paras[i + 1].split("\n")[0]) and not (i + 2 == len(paras) and asks(paras[i + 1])))):
635
812
  i += 1
636
813
  block.append(paras[i])
637
- if len(fence.findall(paras[i])) % 2 == 1:
814
+ if fences(paras[i]) % 2 == 1:
638
815
  fenced = not fenced
639
- add_block(add, re.sub(r"[#::\[\]=]", "", head).strip(), block)
816
+ # A block that opens on a record's own label ("[已选个人资料开始]" under "参考资料:") is that record.
817
+ inner = block[0].lstrip().split("\n")[0]
818
+ label = inner if heading.match(inner) and is_record(inner) else head
819
+ add_block(add, name_of(label), block, kind_of(label))
640
820
  else:
641
- label = next((m for m in (inline.match(l) for l in lines) if m and named.search(m.group(1))), None)
642
- if label:
643
- add(label.group(1).strip(), paras[i])
821
+ found = next((m for m in (inline.match(l) for l in lines) if m and named.search(m.group(1))), None)
822
+ if found:
823
+ add(found.group(1).strip(), paras[i], kind_of(found.group(1)))
824
+ else:
825
+ data = data_in(paras[i], paras[i - 1] if i else None)
826
+ if data:
827
+ add_block(add, data[0], [data[1]], "data")
644
828
  i += 1
645
829
 
646
830
  def passages_in(sent):
@@ -649,10 +833,10 @@ def _install():
649
833
  return None
650
834
  out = []
651
835
 
652
- def add(name, text):
836
+ def add(name, text, kind=None):
653
837
  t = str(text or "").strip()[:passage_text]
654
838
  if len(t) >= 20 and len(out) < passages_max and all(o["text"] != t for o in out):
655
- out.append({"name": str(name or "")[:80], "text": t})
839
+ out.append({"name": str(name or "")[:80], "text": t, **({"kind": kind} if kind else {})})
656
840
 
657
841
  def scan(content):
658
842
  if isinstance(content, str):
@@ -720,11 +904,154 @@ def _install():
720
904
 
721
905
  def rules_in(sent):
722
906
  found = rules_now()
723
- if found is None:
907
+ # An empty list claims nothing either way: "carried none" needs rules to carry.
908
+ if not found:
724
909
  return None
725
910
  body = norm(sent)
726
911
  return [rid for rid, parts in found if all(p in body for p in parts)]
727
912
 
913
+ # What the person said to the app: every text value requests carried in their bodies and
914
+ # addresses, long enough to tell apart, the newest kept. A model call's prompt is told apart from
915
+ # them whichever side keeps the conversation. Kept by value, not by request: with trials side by
916
+ # side a trial's opening message sat up to 282 requests back, and a window of the last 32 would
917
+ # have told it as the app's own words on 114 of 132 later turns.
918
+ # Bounded by characters as well as count: a raw body is one value, and 2000 of them at 64 KB would
919
+ # hold about 128 MB inside the app.
920
+ heard_kept, heard_chars, heard_max, said_ascii, said_other = 2000, 2_000_000, 200, 8, 4
921
+ heard = {}
922
+ held = [0]
923
+ letter = re.compile(r"[^\W\d_]")
924
+ form = re.compile(r"^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$")
925
+
926
+ def heard_in(req):
927
+ from urllib.parse import parse_qsl
928
+ out = set()
929
+
930
+ def add(s):
931
+ t = s.strip() if isinstance(s, str) else ""
932
+ if len(out) < heard_max and len(t) >= (said_ascii if t.isascii() else said_other) and letter.search(t):
933
+ out.add(t)
934
+
935
+ def walk(v, depth):
936
+ if isinstance(v, str):
937
+ add(v)
938
+ elif isinstance(v, (dict, list)) and depth < 12:
939
+ for x in v.values() if isinstance(v, dict) else v:
940
+ walk(x, depth + 1)
941
+
942
+ raw = body_of(req)
943
+ body = parsed(raw)
944
+ if isinstance(body, (dict, list)):
945
+ walk(body, 0)
946
+ elif form.match(raw):
947
+ for _, v in parse_qsl(raw):
948
+ add(v)
949
+ else:
950
+ add(raw)
951
+ for _, v in parse_qsl(req["path"].partition("?")[2]):
952
+ add(v)
953
+ return out
954
+
955
+ def hear(req):
956
+ try:
957
+ for w in heard_in(req):
958
+ if heard.pop(w, None):
959
+ held[0] -= len(w)
960
+ heard[w] = True
961
+ held[0] += len(w)
962
+ while heard and (len(heard) > heard_kept or held[0] > heard_chars):
963
+ w = next(iter(heard))
964
+ del heard[w]
965
+ held[0] -= len(w)
966
+ except Exception:
967
+ pass
968
+
969
+ # What the app told the model on this call: its system prompt and every block it or its framework
970
+ # put among the messages (a reminder, injected context, a prompt template), in prompt order. Never
971
+ # the person's words: what a request carried is taken out of the person's side, and a message
972
+ # that held only that is not told. Never the model's own words or a tool's answer. A reply is
973
+ # read against this, and a block of it the reply repeats is a leak code decides.
974
+ instructions_max, told_min = 12000, 12
975
+ word_char = re.compile(r"[^\W_]")
976
+ # The fields read below, in the shapes they are read in. A body with none of them is a shape
977
+ # this hook cannot read: its instructions stay unrecorded, so the run keeps the read's copy of
978
+ # the prompt, and "" always means the call really carried none.
979
+ told_fields = {"system": (str, list, dict), "instructions": (str, list, dict), "systemInstruction": dict,
980
+ "messages": list, "input": (str, list), "contents": list, "prompt": str}
981
+
982
+ def instructions_in(body, words):
983
+ if not any(isinstance(body.get(k), shape) for k, shape in told_fields.items()):
984
+ return None
985
+ # Longest first, so a short phrase the person repeated never splits a longer message of theirs.
986
+ said = sorted(words, key=len, reverse=True)
987
+ told = []
988
+
989
+ def own(content):
990
+ t = text_of(content).strip()
991
+ if t:
992
+ told.append(t)
993
+
994
+ def added(left):
995
+ for w in said:
996
+ if w in left:
997
+ left = left.replace(w, " ")
998
+ if len(word_char.findall(left)) >= told_min:
999
+ told.append(left.strip())
1000
+
1001
+ def texts(content):
1002
+ return [content] if isinstance(content, str) else [p["text"] for p in items(content) if isinstance(p.get("text"), str)]
1003
+
1004
+ own(body.get("system"))
1005
+ own(body.get("instructions"))
1006
+ if isinstance(body.get("systemInstruction"), dict):
1007
+ own(body["systemInstruction"].get("parts"))
1008
+ for m in items(body.get("messages")):
1009
+ if m.get("role") in ("system", "developer"):
1010
+ own(m.get("content"))
1011
+ elif m.get("role") == "user" and not handed_back.match(text_of(m.get("content"))):
1012
+ for t in texts(m.get("content")):
1013
+ added(t)
1014
+ inp = body.get("input")
1015
+ for it in [{"role": "user", "content": inp}] if isinstance(inp, str) else items(inp):
1016
+ if it.get("role") in ("system", "developer"):
1017
+ own(it.get("content"))
1018
+ elif it.get("role") == "user":
1019
+ for t in texts(it.get("content")):
1020
+ added(t)
1021
+ for c in items(body.get("contents")):
1022
+ if c.get("role") != "model":
1023
+ for t in texts(c.get("parts")):
1024
+ added(t)
1025
+ if isinstance(body.get("prompt"), str):
1026
+ added(body["prompt"])
1027
+ return "\n\n".join(told)[:instructions_max]
1028
+
1029
+ # The tools the call offered its model: each by name, a provider's own tool by its type, and the
1030
+ # declared ones as their schemas say: what each does and the inputs it requires.
1031
+ tools_offered, does_max, inputs_max = 60, 300, 12
1032
+
1033
+ def told_of(body, req):
1034
+ words = heard.keys() | heard_in(req) if req else heard.keys()
1035
+ provider = [t["type"] for t in items(body.get("tools")) if not t.get("name") and not t.get("function") and isinstance(t.get("type"), str) and t["type"] not in ("function", "custom")]
1036
+ offered = list(dict.fromkeys(sorted(declared_in(body)) + provider))[:tools_offered]
1037
+ declared = []
1038
+ for d in list({d["name"]: d for d in tool_defs(body) if d["schema"]}.values())[:tools_offered]:
1039
+ required = d["schema"].get("required")
1040
+ entry = {"name": d["name"][:80]}
1041
+ if d["does"].strip():
1042
+ entry["does"] = d["does"].strip()[:does_max]
1043
+ entry["requires"] = [k for k in required if isinstance(k, str)][:inputs_max] if isinstance(required, list) else []
1044
+ declared.append(entry)
1045
+ out = {}
1046
+ instructions = instructions_in(body, words)
1047
+ if instructions is not None:
1048
+ out["instructions"] = instructions
1049
+ if offered:
1050
+ out["offered"] = offered
1051
+ if declared:
1052
+ out["declared"] = declared
1053
+ return out
1054
+
728
1055
  # Every value this file writes passes one mask, whichever path wrote it: a key inside a value
729
1056
  # ("sk-...", a bearer token, a JWT) and what anyone wrote after "password is" or "token:". The
730
1057
  # door row's sign-in headers are kept on purpose: the command replays them from this machine.
@@ -753,12 +1080,30 @@ def _install():
753
1080
  b = parsed(raw)
754
1081
  return json.dumps(scrub(b), ensure_ascii=False) if isinstance(b, (dict, list)) else mask_text(raw)
755
1082
 
1083
+ # A reply is JSON, or a stream of JSON events: masked the same way, event by event, so a stream
1084
+ # whose events say {"token": "..."} keeps its words.
1085
+ def reply_scrubbed(raw):
1086
+ whole = parsed(raw)
1087
+ if isinstance(whole, (dict, list)):
1088
+ return json.dumps(scrub(whole), ensure_ascii=False)
1089
+ out = []
1090
+ for line in raw.split("\n"):
1091
+ m = re.match(r"^(data:\s?)?([{\[].*)$", line)
1092
+ out.append((m.group(1) or "") + body_scrubbed(m.group(2)) if m else mask_text(line))
1093
+ return "\n".join(out)
1094
+
756
1095
  def write(row):
757
1096
  try:
758
1097
  if not (row.get("hello") or row.get("listen") or row.get("routes")):
759
1098
  out = scrub(row)
760
1099
  if isinstance(row.get("body"), str):
761
1100
  out["body"] = body_scrubbed(row["body"])
1101
+ if isinstance(row.get("reply"), str):
1102
+ out["reply"] = reply_scrubbed(row["reply"])
1103
+ if isinstance(row.get("sent"), list):
1104
+ out["sent"] = [body_scrubbed(x) for x in row["sent"] if isinstance(x, str)]
1105
+ if isinstance(row.get("after"), list):
1106
+ out["after"] = [{**s, "body": body_scrubbed(raw["body"]), "reply": reply_scrubbed(raw["reply"])} for s, raw in zip(out["after"], row["after"])]
762
1107
  row = out
763
1108
  fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
764
1109
  with os.fdopen(fd, "a", encoding="utf-8") as f:
@@ -769,13 +1114,15 @@ def _install():
769
1114
  # Every other outbound call: the host and what it answered, never a byte of it. A search
770
1115
  # provider over its limit for a whole run was invisible until this row existed.
771
1116
  # A store on this machine (a local Qdrant or Chroma) is said too when it answered with passages.
772
- def dep(url, status, code=None, passages=None, turn=None):
1117
+ # A retrieval says so, with the app's line that asked, so one that came back empty has an address.
1118
+ def dep(url, status, code=None, passages=None, req=None, caller=None):
773
1119
  try:
774
1120
  host = (urlsplit(str(url)).hostname or "").lower()
775
- if not host or (host in ("localhost", "127.0.0.1", "::1") and not passages):
1121
+ retrieval = is_retrieval(url)
1122
+ if not host or (host in ("localhost", "127.0.0.1", "::1") and not passages and not retrieval):
776
1123
  return
777
- turn = turn or turn_now()
778
- 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 {}), **({"passages": passages} if passages else {})}})
1124
+ write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **inside_of(req or ctx.get()),
1125
+ **({"retrieval": True} if retrieval else {}), **({"caller": caller} if retrieval and caller else {}), **({"passages": passages} if passages else {})}})
779
1126
  except Exception:
780
1127
  pass
781
1128
  def is_model_call(url):
@@ -789,31 +1136,421 @@ def _install():
789
1136
  return bool(re.search(r"generativelanguage|aiplatform", host)) and bool(model_path.search(path))
790
1137
  if host.endswith("amazonaws.com"):
791
1138
  return host.startswith("bedrock")
792
- return bool(model_host.search(host)) or (bool(model_path.search(path)) and bool(re.search(r"/v\d|/api/", path)))
793
-
794
- def text(body):
1139
+ # The OpenAI shape's own paths name a model call wherever they are served: LiteLLM's proxy is
1140
+ # called at plain /chat/completions (wire.cjs OPENAI_PATH).
1141
+ if model_host.search(host) or re.search(r"/(?:chat/completions|completions|embeddings)$", path, re.I):
1142
+ return True
1143
+ # /messages and /responses only as the two APIs spell them (wire.cjs AS_SPELLED).
1144
+ if re.search(r"/(?:messages|responses)$", path, re.I):
1145
+ return bool(re.search(r"/v1/(?:messages|responses)$", path, re.I)) and not messaging_host.search(host)
1146
+ return bool(model_path.search(path)) and bool(re.search(r"/v\d|/api/", path))
1147
+
1148
+ # `most`: what is read of a body. The door's row keeps 64 KB; a model call's prompt is read whole
1149
+ # up to the reply's bound, since a prompt cut at 64 KB is not JSON and every passage, tool answer
1150
+ # and call in it was lost (resumeforge's tool list alone pushed its prompt past the cut).
1151
+ def text(body, most=limit):
795
1152
  if body is None:
796
1153
  return ""
797
1154
  if isinstance(body, (bytes, bytearray)):
798
- return bytes(body[:limit]).decode("utf-8", "replace")
1155
+ return bytes(body[:most]).decode("utf-8", "replace")
799
1156
  if isinstance(body, str):
800
- return body[:limit]
1157
+ return body[:most]
801
1158
  try:
802
- return json.dumps(body)[:limit]
1159
+ return json.dumps(body)[:most]
803
1160
  except (TypeError, ValueError):
804
1161
  return ""
805
1162
 
1163
+ # A model request kept whole when it fits; a longer one keeps its shape and every value short
1164
+ # enough to be a turn, its longest texts cut in their middle (lib/wire.cjs fitted).
1165
+ def fitted(body):
1166
+ whole = text(body, reply_max)
1167
+ if len(whole) <= limit:
1168
+ return whole
1169
+ try:
1170
+ parsed = json.loads(whole)
1171
+ except ValueError:
1172
+ parsed = None
1173
+ cap = limit // 2
1174
+ while isinstance(parsed, (dict, list)) and cap >= 256:
1175
+ def cut(v):
1176
+ if isinstance(v, str):
1177
+ return v if len(v) <= cap else v[:cap // 2] + " [...] " + v[-(cap // 2):]
1178
+ if isinstance(v, list):
1179
+ return [cut(x) for x in v]
1180
+ return {k: cut(x) for k, x in v.items()} if isinstance(v, dict) else v
1181
+ out = json.dumps(cut(parsed), ensure_ascii=False)
1182
+ if len(out) <= limit:
1183
+ return out
1184
+ cap //= 2
1185
+ return whole[:limit]
1186
+
1187
+ # An exchange is kept when the request makes its first model call, for the first few requests per
1188
+ # route, and written once the app has answered. Model calls and tool rows carry the request's id
1189
+ # whether kept or not; the command joins them to the exchange it has.
1190
+ exchanges_per_route, canary_per_route, routes_kept, sent_max, model_words = 4, 30, 1000, 4, 4000
1191
+ per_route = {}
1192
+
1193
+ # The person's requests, ours (a run's turns, a knock) and the canary's each have their own count,
1194
+ # so a run that knocked first never takes the places of the requests that prove a door. Every one
1195
+ # of the person's is kept: their newest is the sign-in and the proof a run takes, and the command
1196
+ # keeps only the newest few per door.
1197
+ def slot_of(turn):
1198
+ if not turn:
1199
+ return "", float("inf")
1200
+ return ("canary ", canary_per_route) if turn.startswith("canary:") else ("ours ", exchanges_per_route)
1201
+ id_segment = re.compile(r"^\d+$|^(?=.{8,}$).*\d")
1202
+
1203
+ # One route whatever id its path carries: a conversation in the path is a new path every trial.
1204
+ def route_key(method, path):
1205
+ return method + " " + "/".join("{id}" if id_segment.match(s) else s for s in path.split("?")[0].split("/"))
1206
+
1207
+ # The request a model call is pinned to, returned so the call's row carries it: the one it was
1208
+ # made inside, else the request that asked for it (cortad_tied).
806
1209
  def note(url, body):
807
1210
  req = ctx.get()
808
- if not req or req["noted"] or not is_model_call(url):
1211
+ if not is_model_call(url):
1212
+ return req
1213
+ if tied:
1214
+ tied.sent(text(body))
1215
+ if not req and tied:
1216
+ try:
1217
+ req = tied.pinned(text(body, reply_max))
1218
+ except Exception:
1219
+ req = None
1220
+ elif req and tied:
1221
+ try:
1222
+ req = tied.inside(req, text(body, reply_max))
1223
+ except Exception:
1224
+ pass
1225
+ if not req:
1226
+ return None
1227
+ if not req["noted"]:
1228
+ req["noted"] = True
1229
+ cls, most = slot_of(req["turn"])
1230
+ key = cls + route_key(req["method"], req["path"])
1231
+ n = per_route.get(key, 0)
1232
+ if n < most and (n or len(per_route) < routes_kept):
1233
+ per_route[key] = n + 1
1234
+ req["kept"] = True
1235
+ if req["kept"] and len(req["sent"]) < sent_max:
1236
+ req["sent"].append(fitted(body))
1237
+ return req
1238
+
1239
+ def noted(url, sent):
1240
+ try:
1241
+ return note(url, sent)
1242
+ except Exception:
1243
+ return ctx.get()
1244
+
1245
+ # A model call made for `req` got its response, as it arrives (cortad_tied `replied`).
1246
+ def answered_call(url, req):
1247
+ if req and is_model_call(url):
1248
+ req["heard"] = True
1249
+
1250
+ # Outbound writes a trial makes are held in the app, never sent: a trial that books, charges,
1251
+ # emails or deletes would otherwise do it for real. Held: a call made inside a request the run
1252
+ # tagged, with a writing method, to a host that is not a model, a retrieval, this machine, a copy
1253
+ # of ours or a host the person said yes to. A call carrying a provider's test key goes to that
1254
+ # provider's own sandbox as sent. The app is answered with a success shaped like what it sent,
1255
+ # and the run is told by a dep row with `held` and the clipped call (lib/trace.cjs does the same).
1256
+ import base64
1257
+ from urllib.parse import parse_qsl
1258
+
1259
+ passed = {"at": -1, "hosts": []}
1260
+ # When the run last sent a request: a write made outside every request (a queue's worker, a
1261
+ # thread) is held while a run is live, since it may be carrying out a trial's ask.
1262
+ # ponytail: while a run is live, a person's own queued write is held too; per-ask pinning of
1263
+ # background writes is the upgrade when that matters.
1264
+ run_seen = {"at": None}
1265
+ run_live_s = 600
1266
+ this_machine = re.compile(r"^(?:localhost|127(?:\.\d+){3}|::1|0\.0\.0\.0)$", re.I)
1267
+ # ponytail: a sign-in's token refresh is a POST that changes nothing, told apart by its path only.
1268
+ token_path = re.compile(r"/(?:oauth2?/)?token(?:[/?]|$)", re.I)
1269
+ # A provider's own sandbox, named so in the host: api.sandbox.paypal.com, sandbox-api.polar.sh.
1270
+ sandbox_host = re.compile(r"(?:^|[.-])(?:sandbox|sbox)[.-]", re.I)
1271
+ test_setting = re.compile(r"(?:^|_)TEST(?:_|$)", re.I)
1272
+ # An API where every call is a POST names what a call does in its last segment, as
1273
+ # resource.verb_words (customers.get, billing.preview_attach) or as a gRPC or Connect method
1274
+ # (acme.v1.UserService/GetUser). A POST so named that starts with a reading word, and holds no word
1275
+ # that makes the read a write (get_or_create, check_in, GetLease), goes out.
1276
+ # ponytail: decided on the name alone, so a POST named as a read whose body writes goes out: a
1277
+ # check that also records usage, or a file created under a name such as allow.list.
1278
+ dot_call = re.compile(r"[A-Za-z][\w-]*\.[a-z][a-z_]*", re.A)
1279
+ dot_read = {"get", "list", "check", "search", "query", "find", "lookup", "retrieve", "describe", "count", "preview"}
1280
+ # A preview commits nothing whatever it previews; any other reading word reads only while no word
1281
+ # after it writes: credits.check_deduct and usage.count_increment went out as reads.
1282
+ write_word = set("create|update|delete|remove|set|add|put|insert|upsert|write|save|store|record|track|log|send|deduct|increment|decrement|consume|use|spend|charge|pay|refund|cancel|reserve|lock|unlock|commit|apply|attach|detach|start|stop|run|trigger|reset|rotate|revoke|grant|issue|mint|transfer|move|merge|sync|import|export|upload|publish|enqueue|push|pop|ack|touch|mark|close|approve|reject|submit|confirm|redeem|claim".split("|"))
1283
+ dot_not = {"or", "and", "in", "out", "next"} | write_word
1284
+ rpc_call = re.compile(r"(?:[A-Z][a-z0-9]*)+")
1285
+ rpc_read = {"Get", "List", "Search", "Query", "Find", "Lookup", "Describe"}
1286
+ rpc_not = {"or", "and", "next", "lease"} | write_word
1287
+ service_name = re.compile(r"\.[A-Z]\w*\Z", re.A)
1288
+
1289
+ def reads_by_name(method, path):
1290
+ if str(method or "").upper() != "POST":
1291
+ return False
1292
+ parts = [p for p in (path or "").split("/") if p]
1293
+ last = parts[-1] if parts else ""
1294
+ before = parts[-2] if len(parts) > 1 else ""
1295
+ if dot_call.fullmatch(last):
1296
+ words = last.split(".")[1].split("_")
1297
+ return words[0] in dot_read and (words[0] == "preview" or not any(w in dot_not for w in words[1:]))
1298
+ if service_name.search(before) and rpc_call.fullmatch(last):
1299
+ words = re.findall(r"[A-Z][a-z0-9]*", last)
1300
+ return words[0] in rpc_read and not any(w.lower() in rpc_not for w in words[1:])
1301
+ return False
1302
+
1303
+ def pass_now():
1304
+ try:
1305
+ at = os.stat(_OUTBOUND).st_mtime_ns
1306
+ if at != passed["at"]:
1307
+ passed["at"] = at
1308
+ with open(_OUTBOUND, encoding="utf-8") as f:
1309
+ got = json.load(f).get("pass")
1310
+ passed["hosts"] = [h for h in got if isinstance(h, str) and h] if isinstance(got, list) else []
1311
+ except Exception:
1312
+ pass
1313
+ return passed["hosts"]
1314
+
1315
+ # A provider's test-mode key, named so in the key itself whatever comes before it (Stripe's
1316
+ # sk_test_, am_sk_test_, pdl_sdbx_), or a value of a setting the app keeps as a test one.
1317
+ def test_key(auth):
1318
+ # `requests` takes a header value as bytes, and str() of one is "b'...'", which no key matches.
1319
+ a = auth.decode("utf-8", "replace") if isinstance(auth, (bytes, bytearray)) else str(auth or "")
1320
+ m = re.match(r"^Basic\s+(\S+)$", a, re.I)
1321
+ if m:
1322
+ try:
1323
+ a = base64.b64decode(m.group(1)).decode("utf-8", "replace")
1324
+ except Exception:
1325
+ pass
1326
+ # In the prefix of the key itself, set off by underscores: a Basic user named test-admin and a
1327
+ # live key holding "-Test-" are not test keys.
1328
+ return bool(a) and (any(re.fullmatch(r"(?:[a-z0-9]{2,8}_){0,3}(?:test|sandbox|sbox|sdbx)_[\w-]{4,}", piece, re.I | re.A) for piece in re.split(r"[\s:]+", a)) or any(v and len(v) >= 8 and v in a for k, v in os.environ.items() if test_setting.search(k)))
1329
+
1330
+ # The headers a key travels in.
1331
+ key_headers = ("authorization", "x-api-key", "api-key", "apikey", "x-auth-token")
1332
+
1333
+ def keys_in(headers):
1334
+ return [headers.get(n) for n in key_headers]
1335
+
1336
+ # `keys_of()`: the values of the headers a key travels in, read only for a call that is otherwise
1337
+ # held. Once a tagged write is being decided, a failure to decide holds it.
1338
+ def held_here(url, method, keys_of):
1339
+ req = ctx.get()
1340
+ if str(method or "").upper() not in ("POST", "PUT", "PATCH", "DELETE"):
1341
+ return False
1342
+ if req:
1343
+ if not req.get("turn"):
1344
+ return False
1345
+ elif run_seen["at"] is None or time.monotonic() - run_seen["at"] > run_live_s:
1346
+ return False
1347
+ try:
1348
+ parts = urlsplit(str(url))
1349
+ host = (parts.hostname or "").lower()
1350
+ if this_machine.match(host) or sandbox_host.search(host) or is_model_call(url) or is_retrieval(url) or token_path.search(parts.path or "") or reads_by_name(method, parts.path) or any(test_key(k) for k in keys_of()):
1351
+ return False
1352
+ return not any(host == p or host.endswith("." + p) for p in pass_now())
1353
+ except Exception:
1354
+ return True
1355
+
1356
+ # A GraphQL query reads; a mutation writes. A `query` that is not GraphQL (SQL over HTTP) is held.
1357
+ def reads_only(raw):
1358
+ b = parsed(raw) if raw else None
1359
+ q = b.get("query") if isinstance(b, dict) else None
1360
+ return isinstance(q, str) and bool(re.match(r"^\s*(?:\{|query\b|fragment\b)", q)) and not re.search(r"\bmutation\b", q)
1361
+
1362
+ held_count = itertools.count(1)
1363
+
1364
+ def fields_of(raw, kind):
1365
+ try:
1366
+ b = dict(parse_qsl(raw)) if "form" in (kind or "") else json.loads(raw)
1367
+ except Exception:
1368
+ return {}
1369
+ return b if isinstance(b, dict) else {}
1370
+
1371
+ def held_reply(raw, kind):
1372
+ return json.dumps({**fields_of(raw, kind), "id": "cortad-held-%d" % next(held_count), "status": "ok"}).encode("utf-8")
1373
+
1374
+ # Said once per method and host in the app's own output, where the error that follows is read:
1375
+ # a stand-in an SDK cannot parse otherwise reads as a bug in the app. It holds no word that marks
1376
+ # an error, so it is never quoted back as the app's own.
1377
+ held_said = set()
1378
+
1379
+ def held_say(method, host):
1380
+ if (method, host) in held_said:
809
1381
  return
810
- req["noted"] = True
811
- key = req["method"] + " " + req["path"].split("?")[0]
812
- if key in said:
1382
+ held_said.add((method, host))
1383
+ try:
1384
+ sys.stderr.write("cortad: a request Cortad sent made this app send %s to %s. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line comes from that stand-in, not from your code. With that service's test key the call goes out as sent.\n" % (method, host))
1385
+ except Exception:
1386
+ pass
1387
+
1388
+ # Named by method and host only: a webhook's path is its credential.
1389
+ def held_row(url, method, raw, kind, req):
1390
+ try:
1391
+ parts = urlsplit(str(url))
1392
+ held_say(str(method).upper(), (parts.hostname or "")[:253])
1393
+ write({"dep": {"at": int(time.time() * 1000), "host": (parts.hostname or "")[:253], "status": 200, "held": True, **inside_of(req),
1394
+ "called": [{"name": ("%s %s" % (str(method).upper(), parts.hostname))[:80], "arguments": args_text(fields_of(raw, kind))}]}})
1395
+ except Exception:
1396
+ pass
1397
+
1398
+ # aiohttp builds its own response from a connection, so a held call is sent to a server of this
1399
+ # hook's own on this machine, which answers it.
1400
+ held_server = {"port": None}
1401
+ held_waiting = {}
1402
+ held_sent = itertools.count(1)
1403
+
1404
+ def held_port():
1405
+ if held_server["port"] is None:
1406
+ try:
1407
+ import threading
1408
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1409
+
1410
+ class Answer(BaseHTTPRequestHandler):
1411
+ def answer(self):
1412
+ size = int(self.headers.get("content-length") or 0)
1413
+ raw = self.rfile.read(min(size, limit)).decode("utf-8", "replace") if size else ""
1414
+ kind = self.headers.get("content-type", "")
1415
+ waiting = held_waiting.pop(self.headers.get("x-cortad-held", ""), None)
1416
+ if waiting:
1417
+ held_row(waiting[0], waiting[1], raw, kind, waiting[2])
1418
+ out = held_reply(raw, kind)
1419
+ self.send_response(200)
1420
+ self.send_header("content-type", "application/json")
1421
+ self.send_header("content-length", str(len(out)))
1422
+ self.end_headers()
1423
+ self.wfile.write(out)
1424
+
1425
+ do_POST = do_PUT = do_PATCH = do_DELETE = answer
1426
+
1427
+ def log_message(self, *a):
1428
+ pass
1429
+
1430
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Answer)
1431
+ server.daemon_threads = True
1432
+ _held_ports.append(server.server_address[1])
1433
+ threading.Thread(target=server.serve_forever, daemon=True).start()
1434
+ held_server["port"] = server.server_address[1]
1435
+ except Exception:
1436
+ return None
1437
+ return held_server["port"]
1438
+
1439
+ def said_of(req, name):
1440
+ return [v for k, v in req.get("reply_headers") or [] if k == name]
1441
+
1442
+ def body_of(req):
1443
+ return b"".join(req["chunks"]).decode("utf-8", "replace")[:limit]
1444
+
1445
+ def reply_of(req):
1446
+ return unpacked(b"".join(req["reply"]), (said_of(req, "content-encoding") or [""])[0])[:limit]
1447
+
1448
+ def row_of(req):
1449
+ row = {"at": req["at"], "ms": (req.get("ended") or int(time.time() * 1000)) - req["at"], "method": req["method"], "path": req["path"],
1450
+ "body": body_of(req), "status": int(req.get("status") or 0), "type": (said_of(req, "content-type") or [""])[0], "writes": req["writes"],
1451
+ "reply": reply_of(req)}
1452
+ cookies = [c.split(";")[0].split("=")[0].strip() for c in said_of(req, "set-cookie")][:20]
1453
+ if cookies:
1454
+ row["cookies"] = cookies
1455
+ # The app began its reply and it never finished, so the row holds part of it. Either side may
1456
+ # have closed it (a client that stopped reading, an app that broke off mid-stream), so nothing
1457
+ # downstream says which.
1458
+ if req.get("status") and not req.get("finished"):
1459
+ row["cut"] = True
1460
+ return row
1461
+
1462
+ # An answer that came on a second request is written as that request's row under the id of the
1463
+ # request that asked, with the requests of its turn in `after` (the one that asked last) and the
1464
+ # headers they were sent with: one line, so a reader never sees half a turn.
1465
+ def answered(req, steps=()):
1466
+ asker = steps[-1] if steps else req
1467
+ if not asker.get("kept") or asker.get("written"):
813
1468
  return
814
- said.add(key)
815
- write({"at": int(time.time() * 1000), "method": req["method"], "path": req["path"], "headers": req["headers"],
816
- "body": b"".join(req["chunks"]).decode("utf-8", "replace")[:limit], "sent": text(body)})
1469
+ asker["written"] = True
1470
+ headers = {}
1471
+ for step in steps:
1472
+ headers.update(step["headers"])
1473
+ row = {"ex": asker["id"], **row_of(req), "headers": {**headers, **req["headers"]}, "sent": asker["sent"]}
1474
+ if steps:
1475
+ row["after"] = [row_of(step) for step in steps]
1476
+ turn = req.get("turn") or asker.get("turn")
1477
+ if turn:
1478
+ row["turn"] = turn
1479
+ write(row)
1480
+
1481
+ # A request answered later by its model is written once its calls have gone quiet, marked
1482
+ # `later`, in the time its model took to answer it; a run's turn also gets the model's last words,
1483
+ # the answer the command hands the run. Only an answer settles it: a call that came back 2xx with
1484
+ # no words (a model asking for a tool) is a step, and the next call is waited for (lib/trace.cjs).
1485
+ # ponytail: a fixed settle; after a step that answers in words, more than this of the app's own
1486
+ # work before the next call cuts the answer at that step. A call whose words match only a request
1487
+ # already settled is the evidence: a settle measured per door when one is seen.
1488
+ settle_s = 5.0
1489
+
1490
+ # The turn's answer is written once the calls are quiet even while a read the hook took for a fetch
1491
+ # of it is open (the person's page polling the sender): the answer is the model's words either way.
1492
+ # The exchange is written as answered later only when no fetch came to complete it (tied.settled).
1493
+ def answered_later(req):
1494
+ status, words, at = req["said"]
1495
+ if req.get("turn") and not req.get("turn_answered"):
1496
+ req["turn_answered"] = True
1497
+ write({"answer": {"ex": req["id"], "turn": req["turn"], "status": status, "reply": words}})
1498
+ if tied.settled(req) and req.get("kept"):
1499
+ row = {"ex": req["id"], **row_of(req), "ms": at - req["at"], "later": True, "headers": req["headers"], "sent": req["sent"]}
1500
+ if req.get("turn"):
1501
+ row["turn"] = req["turn"]
1502
+ write(row)
1503
+
1504
+ def settle(req):
1505
+ import threading
1506
+
1507
+ def quiet():
1508
+ try:
1509
+ if not req.get("calls_open") and not req.get("waiting"):
1510
+ answered_later(req)
1511
+ except Exception:
1512
+ pass
1513
+ was = req.get("settling")
1514
+ if was:
1515
+ was.cancel()
1516
+ said = req.get("said")
1517
+ if not said or (not said[1] and 200 <= said[0] < 300):
1518
+ return
1519
+ req["settling"] = threading.Timer(settle_s, quiet)
1520
+ req["settling"].daemon = True
1521
+ req["settling"].start()
1522
+
1523
+ # Where the app's own code wrote the parts of this reply, under the id its exchange is written by.
1524
+ def written_at(req, steps):
1525
+ asker = steps[-1] if steps else req
1526
+ turn = req.get("turn") or asker.get("turn")
1527
+ if req.get("sites") and (asker.get("kept") or turn):
1528
+ write({"sites": {"ex": asker["id"], **({"turn": turn} if turn else {}), "list": req["sites"]}})
1529
+
1530
+ def ended(req):
1531
+ req["ended"] = int(time.time() * 1000)
1532
+ hear(req)
1533
+ steps = tied.closed(req) if tied else None
1534
+ written_at(req, steps)
1535
+ if steps:
1536
+ answered(req, steps)
1537
+ elif req.get("late"):
1538
+ settle(req)
1539
+ elif not req.get("calls_open"):
1540
+ answered(req)
1541
+ # A message taken and answered at once with no model call inside it (lib/wire.cjs `receipt`).
1542
+ status = int(req.get("status") or 0)
1543
+ if not req.get("noted") and not steps and req["method"] != "GET" and 200 <= status < 300 and tied and tied.sentences(req):
1544
+ receipt = {"ex": req["id"], "at": req["at"], "ms": req["ended"] - req["at"], "method": req["method"], "path": req["path"], "status": status}
1545
+ if req.get("turn"):
1546
+ receipt["turn"] = req["turn"]
1547
+ write({"receipt": receipt})
1548
+
1549
+ try:
1550
+ from cortad_tied import Tied
1551
+ tied = Tied(norm, body_of, reply_of)
1552
+ except Exception:
1553
+ tied = None
817
1554
 
818
1555
  # The meter: one row per model call your app makes, with the provider's own token counts and
819
1556
  # never the reply's text.
@@ -863,7 +1600,31 @@ def _install():
863
1600
  if isinstance(part, dict):
864
1601
  usage.update(part)
865
1602
  model = e.get("model") or msg.get("model") or resp.get("model") or e.get("modelVersion") or model
866
- return tokens_of(usage or None), model, events
1603
+ return tokens_of(usage or None), model, events, usage
1604
+
1605
+ # The model stopped because it reached the output cap its request set, in each provider's words: its
1606
+ # answer is cut short or, for a reasoning model, may never have been written.
1607
+ def stopped_at_limit(e):
1608
+ if not isinstance(e, dict):
1609
+ return False
1610
+ r = e["response"] if isinstance(e.get("response"), dict) else e
1611
+ delta = e["delta"] if isinstance(e.get("delta"), dict) else {}
1612
+ choices = e["choices"] if isinstance(e.get("choices"), list) else []
1613
+ candidates = e["candidates"] if isinstance(e.get("candidates"), list) else []
1614
+ return (any(isinstance(c, dict) and c.get("finish_reason") == "length" for c in choices)
1615
+ or (r.get("status") == "incomplete" and (r.get("incomplete_details") or {}).get("reason") == "max_output_tokens")
1616
+ or "max_tokens" in (e.get("stop_reason"), delta.get("stop_reason"), e.get("stopReason"))
1617
+ or any(isinstance(c, dict) and c.get("finishReason") == "MAX_TOKENS" for c in candidates))
1618
+
1619
+ def reasoning_of(u):
1620
+ return number((u.get("output_tokens_details") or {}).get("reasoning_tokens") or (u.get("completion_tokens_details") or {}).get("reasoning_tokens") or u.get("thoughtsTokenCount"))
1621
+
1622
+ # The output cap the app's request set, in each provider's field.
1623
+ def cap_of(b):
1624
+ if not isinstance(b, dict):
1625
+ return None
1626
+ return (number(b.get("max_tokens")) or number(b.get("max_completion_tokens")) or number(b.get("max_output_tokens"))
1627
+ or number((b.get("generationConfig") or {}).get("maxOutputTokens")) or number((b.get("inferenceConfig") or {}).get("maxTokens")) or None)
867
1628
 
868
1629
  def asked_for(url, sent):
869
1630
  body = parsed(sent) if isinstance(sent, str) else None
@@ -890,16 +1651,29 @@ def _install():
890
1651
  # ran by it, where the read could only name the door. Its frames on this stack outside installed
891
1652
  # packages, then, when the call runs in a task another task awaits (LangChain runs each step of a
892
1653
  # chain as its own task), the awaiting task's. Relative to the folder the app was started in.
1654
+ # Five lines: a provider class's stream, post and retry can be three lines of one file, and the
1655
+ # line that says which path ran is past them.
893
1656
  root = os.getcwd().replace(os.sep, "/").rstrip("/") + "/"
1657
+ callers_max = 5
894
1658
  installed = re.compile(r"/(?:site-packages|dist-packages|\.venv|venv|__pypackages__)/|/lib/python\d")
895
1659
 
896
- def ours(frame, out):
1660
+ def own_at(frame):
897
1661
  f = frame.f_code.co_filename.replace(os.sep, "/")
898
1662
  if f.startswith(root) and not installed.search(f[len(root) - 1:]):
899
- at = "%s:%d" % (f[len(root):], frame.f_lineno)
900
- if at not in out:
901
- out.append(at)
902
- return len(out) >= 3
1663
+ return "%s:%d" % (f[len(root):], frame.f_lineno)
1664
+ return None
1665
+
1666
+ def ours(frame, out):
1667
+ at = own_at(frame)
1668
+ if at and at not in out:
1669
+ out.append(at)
1670
+ return len(out) >= callers_max
1671
+
1672
+ try:
1673
+ from cortad_sites import Sites
1674
+ sites = Sites(own_at)
1675
+ except Exception:
1676
+ sites = None
903
1677
 
904
1678
  # The task that waits on this one: its wake-up is among this one's callbacks, directly, or behind
905
1679
  # the future an asyncio.gather callback closes over.
@@ -920,7 +1694,7 @@ def _install():
920
1694
  return None
921
1695
 
922
1696
  # An embedding call answers nobody, so where it was made names no path a reply came down.
923
- embedding = re.compile(r"/embeddings$", re.I)
1697
+ embedding = re.compile(r"/(?:embeddings|embedding-model)$", re.I)
924
1698
 
925
1699
  def callers_now(url):
926
1700
  if embedding.search(urlsplit(str(url)).path or ""):
@@ -955,24 +1729,39 @@ def _install():
955
1729
  pass
956
1730
  return out or None
957
1731
 
958
- def meter(url, sent, status, kind, raw, turn=None, caller=None):
1732
+ def meter(url, sent, status, kind, raw, req=None, caller=None, t0=None):
959
1733
  try:
960
- tokens, model, events = read_reply(kind, (raw or "")[:reply_max])
1734
+ tokens, model, events, usage = read_reply(kind, (raw or "")[:reply_max])
961
1735
  parts = urlsplit(str(url))
962
- row = {"at": int(time.time() * 1000), "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
1736
+ now = int(time.time() * 1000)
1737
+ row = {"at": now, "ms": now - int(t0 * 1000) if t0 else 0, "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
963
1738
  "status": int(status or 0), "usage": tokens is not None}
1739
+ # The model that answered, when a gateway or proxy answered with another than the one asked
1740
+ # for (a dated version of the same model is the same model).
1741
+ asked_model = str(asked_for(url, sent) or "")
1742
+ if model and asked_model and str(model) not in asked_model and asked_model not in str(model):
1743
+ row["answered"] = str(model)[:160]
964
1744
  row.update(tokens or {"promptTokens": 0, "cachedTokens": 0, "completionTokens": 0})
965
- turn = turn or turn_now()
966
- if turn:
967
- row["turn"] = turn
1745
+ if embedding.search(parts.path or ""):
1746
+ row["embedding"] = True
1747
+ if tied:
1748
+ tied.done(req)
1749
+ if req and req.get("late"):
1750
+ if not row.get("embedding"):
1751
+ req["said"] = (row["status"], reply_text(events)[:model_words], now)
1752
+ settle(req)
1753
+ row.update(inside_of(req))
1754
+ if req and (req.get("kept") or req.get("turn")):
1755
+ row["reply"] = reply_text(events)[:model_words]
968
1756
  as_text = sent if isinstance(sent, str) else text(sent)
969
1757
  found = rules_in(as_text)
970
1758
  if found is not None:
971
1759
  row["rules"] = found
972
- tools = tools_in(as_text)
1760
+ provided_calls, provided = provider_in(as_text, events)
1761
+ tools = (tools_in(as_text) or []) + provided
973
1762
  if tools:
974
1763
  row["tools"] = tools
975
- called = called_in(as_text, events)
1764
+ called = called_in(as_text, events, provided_calls)
976
1765
  if called:
977
1766
  row["called"] = called
978
1767
  passages = passages_in(as_text)
@@ -980,6 +1769,11 @@ def _install():
980
1769
  row["passages"] = passages
981
1770
  if caller:
982
1771
  row["caller"] = caller
1772
+ asked = parsed(as_text)
1773
+ if any(stopped_at_limit(e) for e in events):
1774
+ row["limit"] = {"cap": cap_of(asked), "reasoning": reasoning_of(usage)}
1775
+ if isinstance(asked, dict) and not row.get("embedding"):
1776
+ row.update(told_of(asked, req))
983
1777
  write({"call": row})
984
1778
  except Exception:
985
1779
  pass
@@ -1030,7 +1824,7 @@ def _install():
1030
1824
  kind = "list of %d" % len(v) if isinstance(v, (list, tuple)) else "object" if isinstance(v, dict) or hasattr(v, "model_dump") else "value"
1031
1825
  return "%s: %s" % (kind, json.dumps(clipped(plain_of(v), 0, tool_text), ensure_ascii=False, separators=(",", ":")))
1032
1826
 
1033
- def tool_row(name, sig, a, k, value, error, turn):
1827
+ def tool_row(name, sig, a, k, value, error, req):
1034
1828
  try:
1035
1829
  try:
1036
1830
  named = dict(sig.bind_partial(*a, **k).arguments) if sig else None
@@ -1041,9 +1835,8 @@ def _install():
1041
1835
  text = "raised %s: %s" % (type(error).__name__, error) if error is not None else returned(value)
1042
1836
  row = {"at": int(time.time() * 1000), "host": "in-app", "status": 200,
1043
1837
  "called": [{"name": name, "arguments": args_text(plain_of(named))}],
1044
- "tools": [{"name": name, "text": str(text)[:tool_text]}]}
1045
- if turn:
1046
- row["turn"] = turn
1838
+ "tools": [tool_text_row(name, str(text))]}
1839
+ row.update(inside_of(req))
1047
1840
  write({"dep": row})
1048
1841
  except Exception:
1049
1842
  pass
@@ -1057,23 +1850,23 @@ def _install():
1057
1850
  sig = None
1058
1851
  if inspect.iscoroutinefunction(fn):
1059
1852
  async def wrapped(*a, **k):
1060
- turn = turn_now()
1853
+ req = ctx.get()
1061
1854
  try:
1062
1855
  out = await fn(*a, **k)
1063
1856
  except Exception as e:
1064
- tool_row(name, sig, a, k, None, e, turn)
1857
+ tool_row(name, sig, a, k, None, e, req)
1065
1858
  raise
1066
- tool_row(name, sig, a, k, out, None, turn)
1859
+ tool_row(name, sig, a, k, out, None, req)
1067
1860
  return out
1068
1861
  else:
1069
1862
  def wrapped(*a, **k):
1070
- turn = turn_now()
1863
+ req = ctx.get()
1071
1864
  try:
1072
1865
  out = fn(*a, **k)
1073
1866
  except Exception as e:
1074
- tool_row(name, sig, a, k, None, e, turn)
1867
+ tool_row(name, sig, a, k, None, e, req)
1075
1868
  raise
1076
- tool_row(name, sig, a, k, out, None, turn)
1869
+ tool_row(name, sig, a, k, out, None, req)
1077
1870
  return out
1078
1871
  functools.update_wrapper(wrapped, fn)
1079
1872
  wrapped.__cortad_tool__ = fn
@@ -1158,27 +1951,44 @@ def _install():
1158
1951
  if hit is not None and hit[0] is vv:
1159
1952
  v[kk] = hit[1]
1160
1953
 
1954
+ serial = itertools.count(1)
1955
+
1161
1956
  def started(method, path, headers):
1162
1957
  try:
1163
1958
  sweep_tools()
1164
1959
  except Exception:
1165
1960
  pass
1166
1961
  turn = headers.pop("x-cortad-turn", None)
1167
- return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False,
1168
- "turn": turn if isinstance(turn, str) and turn_ok.match(turn) else None}
1962
+ req = {"id": "%d.%d" % (os.getpid(), next(serial)), "at": int(time.time() * 1000), "method": method, "path": path, "headers": headers,
1963
+ "chunks": [], "size": 0, "noted": False, "kept": False, "sent": [], "reply": [], "reply_size": 0, "writes": 0,
1964
+ "turn": turn if isinstance(turn, str) and turn_ok.match(turn) else None}
1965
+ if req["turn"]:
1966
+ run_seen["at"] = time.monotonic()
1967
+ if tied:
1968
+ tied.opened(req)
1969
+ return req
1169
1970
 
1170
1971
  def keep(req, chunk):
1171
1972
  if chunk and req["size"] < limit:
1172
1973
  req["chunks"].append(bytes(chunk))
1173
1974
  req["size"] += len(chunk)
1174
1975
 
1175
- # Inbound, ASGI: FastAPI, Starlette, Django under uvicorn, Quart, Litestar.
1976
+ # What the app sends back, as it sends it.
1977
+ def replied(req, chunk):
1978
+ if chunk:
1979
+ req["writes"] += 1
1980
+ if req["reply_size"] < limit:
1981
+ req["reply"].append(bytes(chunk))
1982
+ req["reply_size"] += len(chunk)
1983
+
1984
+ # Inbound, ASGI: FastAPI, Starlette, Django under uvicorn, Quart, Litestar. A GET is watched but
1985
+ # never made the context a model call is pinned by: it can only be the step an answer comes on.
1176
1986
  class Asgi:
1177
1987
  def __init__(self, app):
1178
1988
  self.app = app
1179
1989
 
1180
1990
  async def __call__(self, scope, receive, send):
1181
- if scope.get("type") != "http" or scope.get("method") in ("GET", "HEAD", "OPTIONS"):
1991
+ if scope.get("type") != "http" or scope.get("method") in ("HEAD", "OPTIONS"):
1182
1992
  return await self.app(scope, receive, send)
1183
1993
  query = scope.get("query_string") or b""
1184
1994
  req = started(scope["method"], scope.get("path", "/") + ("?" + query.decode("latin-1") if query else ""),
@@ -1190,11 +2000,30 @@ def _install():
1190
2000
  keep(req, message.get("body") or b"")
1191
2001
  return message
1192
2002
 
1193
- token = ctx.set(req)
2003
+ async def sending(message):
2004
+ try:
2005
+ if message.get("type") == "http.response.start":
2006
+ req["status"] = message.get("status") or 0
2007
+ req["reply_headers"] = [(k.decode("latin-1").lower(), v.decode("latin-1")) for k, v in message.get("headers") or []]
2008
+ elif message.get("type") == "http.response.body":
2009
+ replied(req, message.get("body") or b"")
2010
+ req["finished"] = not message.get("more_body")
2011
+ if req["finished"] and tied:
2012
+ tied.replied(req, req.get("status"), lambda: reply_of(req))
2013
+ if sites and not req.get("placed"):
2014
+ sites.sent(req, message, sys._getframe(1), Asgi.__call__.__code__)
2015
+ except Exception:
2016
+ pass
2017
+ return await send(message)
2018
+
2019
+ asks = scope["method"] != "GET"
2020
+ token = ctx.set(req) if asks else None
1194
2021
  try:
1195
- return await self.app(scope, seen, send)
2022
+ return await self.app(scope, seen if asks else receive, sending)
1196
2023
  finally:
1197
- ctx.reset(token)
2024
+ if token is not None:
2025
+ ctx.reset(token)
2026
+ ended(req)
1198
2027
 
1199
2028
  # Inbound, WSGI: Flask, Django's runserver, anything under werkzeug.
1200
2029
  class Tee:
@@ -1211,6 +2040,20 @@ def _install():
1211
2040
  keep(self.req, chunk)
1212
2041
  return chunk
1213
2042
 
2043
+ # Werkzeug 3 reads the body into its own buffer when the stream can: without this the read went
2044
+ # past the tee, and every Flask request was written down with an empty body.
2045
+ def readinto(self, b):
2046
+ n = self.stream.readinto(b)
2047
+ if n:
2048
+ keep(self.req, bytes(memoryview(b)[:n]))
2049
+ return n
2050
+
2051
+ def readlines(self, *a):
2052
+ lines = self.stream.readlines(*a)
2053
+ for line in lines:
2054
+ keep(self.req, line)
2055
+ return lines
2056
+
1214
2057
  def __iter__(self):
1215
2058
  for chunk in self.stream:
1216
2059
  keep(self.req, chunk)
@@ -1219,23 +2062,34 @@ def _install():
1219
2062
  def __getattr__(self, name):
1220
2063
  return getattr(self.stream, name)
1221
2064
 
1222
- def inside(req, iterable):
2065
+ def inside(req, iterable, asks):
1223
2066
  # A streamed reply is produced after the handler returns: the model call happens here.
1224
2067
  it = iter(iterable)
1225
2068
  try:
1226
2069
  while True:
1227
- token = ctx.set(req)
2070
+ token = ctx.set(req) if asks else None
1228
2071
  try:
1229
2072
  item = next(it)
1230
2073
  except StopIteration:
2074
+ req["finished"] = True
2075
+ if tied:
2076
+ tied.replied(req, req.get("status"), lambda: reply_of(req))
1231
2077
  return
1232
2078
  finally:
1233
- ctx.reset(token)
2079
+ if token is not None:
2080
+ ctx.reset(token)
2081
+ replied(req, item)
2082
+ if sites:
2083
+ try:
2084
+ sites.iterated(req, iterable, item)
2085
+ except Exception:
2086
+ pass
1234
2087
  yield item
1235
2088
  finally:
1236
2089
  close = getattr(iterable, "close", None)
1237
2090
  if close:
1238
2091
  close()
2092
+ ended(req)
1239
2093
 
1240
2094
  def wsgi(app):
1241
2095
  if getattr(app, "_brainsless", False):
@@ -1243,7 +2097,7 @@ def _install():
1243
2097
 
1244
2098
  def wrapped(environ, start_response):
1245
2099
  method = environ.get("REQUEST_METHOD", "GET")
1246
- if method in ("GET", "HEAD", "OPTIONS"):
2100
+ if method in ("HEAD", "OPTIONS"):
1247
2101
  return app(environ, start_response)
1248
2102
  headers = {k[5:].replace("_", "-").lower(): v for k, v in environ.items() if k.startswith("HTTP_")}
1249
2103
  if environ.get("CONTENT_TYPE"):
@@ -1252,11 +2106,26 @@ def _install():
1252
2106
  req = started(method, (environ.get("SCRIPT_NAME", "") + environ.get("PATH_INFO", "/")) + ("?" + query if query else ""), headers)
1253
2107
  if environ.get("wsgi.input") is not None:
1254
2108
  environ["wsgi.input"] = Tee(environ["wsgi.input"], req)
1255
- token = ctx.set(req)
2109
+
2110
+ def starting(status, response_headers, *a):
2111
+ try:
2112
+ req["status"] = int(str(status).split(" ", 1)[0])
2113
+ req["reply_headers"] = [(str(k).lower(), str(v)) for k, v in response_headers]
2114
+ except Exception:
2115
+ pass
2116
+ return start_response(status, response_headers, *a)
2117
+
2118
+ asks = method != "GET"
2119
+ token = ctx.set(req) if asks else None
1256
2120
  try:
1257
- return inside(req, app(environ, start_response))
2121
+ out = app(environ, starting)
2122
+ except BaseException:
2123
+ ended(req)
2124
+ raise
1258
2125
  finally:
1259
- ctx.reset(token)
2126
+ if token is not None:
2127
+ ctx.reset(token)
2128
+ return inside(req, out, asks)
1260
2129
 
1261
2130
  wrapped._brainsless = True
1262
2131
  return wrapped
@@ -1482,7 +2351,7 @@ def _install():
1482
2351
 
1483
2352
  def sent_of(request):
1484
2353
  try:
1485
- return text(request.content)
2354
+ return text(request.content, reply_max)
1486
2355
  except Exception:
1487
2356
  return ""
1488
2357
 
@@ -1527,7 +2396,7 @@ def _install():
1527
2396
  finally:
1528
2397
  self.finish()
1529
2398
 
1530
- def watch(request, response, sent, caller):
2399
+ def watch(request, response, sent, caller, t0, req):
1531
2400
  model = is_model_call(request.url)
1532
2401
  if not model and not is_retrieval(request.url):
1533
2402
  dep(request.url, response.status_code)
@@ -1535,67 +2404,78 @@ def _install():
1535
2404
  kind = response.headers.get("content-type", "")
1536
2405
  if getattr(response, "_content", None) is not None:
1537
2406
  if model:
1538
- meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"), caller=caller)
2407
+ meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"), req, caller, t0)
1539
2408
  else:
1540
- dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")))
2409
+ dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")), caller=caller)
1541
2410
  return response
1542
- turn = turn_now()
1543
2411
  if model:
1544
- done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), turn, caller)
2412
+ done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), req, caller, t0)
1545
2413
  else:
1546
- done = lambda raw: dep(request.url, response.status_code, passages=passages_from(unpacked(raw, response.headers.get("content-encoding"))), turn=turn)
2414
+ done = lambda raw: dep(request.url, response.status_code, passages=passages_from(unpacked(raw, response.headers.get("content-encoding"))), req=req, caller=caller)
1547
2415
  stream = response.stream
1548
2416
  if hasattr(stream, "__aiter__") and hasattr(stream, "aclose"):
1549
2417
  response.stream = AsyncKept(stream, done)
1550
2418
  elif hasattr(stream, "__iter__"):
1551
2419
  response.stream = SyncKept(stream, done)
1552
2420
  elif model:
1553
- meter(request.url, sent, response.status_code, kind, "", caller=caller)
2421
+ meter(request.url, sent, response.status_code, kind, "", req, caller, t0)
1554
2422
  else:
1555
- dep(request.url, response.status_code)
2423
+ dep(request.url, response.status_code, req=req, caller=caller)
1556
2424
  return response
1557
2425
 
2426
+ # A held call that cannot be answered fails in the app; it is never sent instead.
2427
+ def held_httpx(request, sent):
2428
+ if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent):
2429
+ return None
2430
+ kind = request.headers.get("content-type", "")
2431
+ held_row(request.url, request.method, sent, kind, ctx.get())
2432
+ return module.Response(200, headers={"content-type": "application/json"}, content=held_reply(sent, kind), request=request)
2433
+
1558
2434
  for cls in (module.Client, module.AsyncClient):
1559
2435
  send = cls.send
1560
2436
  if cls is module.Client:
1561
2437
  def sync_send(self, request, *a, _send=send, **k):
1562
2438
  sent = sent_of(request)
1563
- caller = callers_now(request.url) if is_model_call(request.url) else None
1564
- try:
1565
- note(request.url, sent)
1566
- except Exception:
1567
- pass
2439
+ held = held_httpx(request, sent)
2440
+ if held is not None:
2441
+ return held
2442
+ caller = callers_now(request.url) if is_model_call(request.url) or is_retrieval(request.url) else None
2443
+ t0 = time.time()
2444
+ req = noted(request.url, sent)
1568
2445
  try:
1569
2446
  response = _send(self, request, *a, **k)
2447
+ answered_call(request.url, req)
1570
2448
  except Exception as err:
1571
2449
  if is_model_call(request.url):
1572
- meter(request.url, sent, 0, "", "", caller=caller)
2450
+ meter(request.url, sent, 0, "", "", req, caller, t0)
1573
2451
  else:
1574
- dep(request.url, 0, type(err).__name__)
2452
+ dep(request.url, 0, type(err).__name__, caller=caller)
1575
2453
  raise
1576
2454
  try:
1577
- return watch(request, response, sent, caller)
2455
+ return watch(request, response, sent, caller, t0, req)
1578
2456
  except Exception:
1579
2457
  return response
1580
2458
  cls.send = sync_send
1581
2459
  else:
1582
2460
  async def async_send(self, request, *a, _send=send, **k):
1583
2461
  sent = sent_of(request)
1584
- caller = callers_now(request.url) if is_model_call(request.url) else None
1585
- try:
1586
- note(request.url, sent)
1587
- except Exception:
1588
- pass
2462
+ held = held_httpx(request, sent)
2463
+ if held is not None:
2464
+ return held
2465
+ caller = callers_now(request.url) if is_model_call(request.url) or is_retrieval(request.url) else None
2466
+ t0 = time.time()
2467
+ req = noted(request.url, sent)
1589
2468
  try:
1590
2469
  response = await _send(self, request, *a, **k)
2470
+ answered_call(request.url, req)
1591
2471
  except Exception as err:
1592
2472
  if is_model_call(request.url):
1593
- meter(request.url, sent, 0, "", "", caller=caller)
2473
+ meter(request.url, sent, 0, "", "", req, caller, t0)
1594
2474
  else:
1595
- dep(request.url, 0, type(err).__name__)
2475
+ dep(request.url, 0, type(err).__name__, caller=caller)
1596
2476
  raise
1597
2477
  try:
1598
- return watch(request, response, sent, caller)
2478
+ return watch(request, response, sent, caller, t0, req)
1599
2479
  except Exception:
1600
2480
  return response
1601
2481
  cls.send = async_send
@@ -1603,26 +2483,47 @@ def _install():
1603
2483
  def patch_requests(module):
1604
2484
  send = module.Session.send
1605
2485
 
2486
+ # A held call that cannot be answered fails in the app; it is never sent instead.
2487
+ def held(request):
2488
+ if not held_here(request.url, request.method, lambda: keys_in(request.headers)):
2489
+ return None
2490
+ raw = text(request.body, reply_max)
2491
+ if reads_only(raw):
2492
+ return None
2493
+ import datetime
2494
+ models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
2495
+ kind = request.headers.get("content-type", "")
2496
+ held_row(request.url, request.method, raw, kind, ctx.get())
2497
+ response = models.Response()
2498
+ response.status_code, response.reason, response.url, response.request, response.encoding = 200, "OK", request.url, request, "utf-8"
2499
+ response.headers = structures.CaseInsensitiveDict({"content-type": "application/json"})
2500
+ response._content, response._content_consumed, response.elapsed = held_reply(raw, kind), True, datetime.timedelta(0)
2501
+ return response
2502
+
1606
2503
  def sent(self, request, *a, **k):
1607
- caller = callers_now(request.url) if is_model_call(request.url) else None
1608
- try:
1609
- note(request.url, request.body)
1610
- except Exception:
1611
- pass
2504
+ answered = held(request)
2505
+ if answered is not None:
2506
+ return answered
2507
+ caller = callers_now(request.url) if is_model_call(request.url) or is_retrieval(request.url) else None
2508
+ t0 = time.time()
2509
+ req = noted(request.url, request.body)
1612
2510
  try:
1613
2511
  response = send(self, request, *a, **k)
2512
+ answered_call(request.url, req)
1614
2513
  except Exception as err:
1615
- if not is_model_call(request.url):
1616
- dep(request.url, 0, type(err).__name__)
2514
+ if is_model_call(request.url):
2515
+ meter(request.url, text(request.body, reply_max), 0, "", "", req, caller, t0)
2516
+ else:
2517
+ dep(request.url, 0, type(err).__name__, caller=caller)
1617
2518
  raise
1618
2519
  try:
1619
2520
  if not is_model_call(request.url):
1620
2521
  read = is_retrieval(request.url) and not k.get("stream")
1621
- dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")) if read else None)
2522
+ dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")) if read else None, caller=caller)
1622
2523
  if is_model_call(request.url):
1623
2524
  # A streamed reply is counted as a call whose counts were not read.
1624
2525
  raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
1625
- meter(request.url, text(request.body), response.status_code, response.headers.get("content-type", ""), raw, caller=caller)
2526
+ meter(request.url, text(request.body, reply_max), response.status_code, response.headers.get("content-type", ""), raw, req, caller, t0)
1626
2527
  except Exception:
1627
2528
  pass
1628
2529
  return response
@@ -1632,22 +2533,55 @@ def _install():
1632
2533
  def patch_aiohttp(module):
1633
2534
  request = module.ClientSession._request
1634
2535
 
1635
- async def requested(self, method, str_or_url, *a, **k):
1636
- body = k.get("json") if k.get("json") is not None else k.get("data")
2536
+ def keys_of(self, k):
2537
+ given = {str(n).lower(): v for n, v in dict(k.get("headers") or {}).items()}
2538
+ defaults = {str(n).lower(): v for n, v in dict(getattr(self, "_default_headers", None) or {}).items()}
2539
+ basic = k.get("auth") or getattr(self, "_default_auth", None)
2540
+ return [given.get(n) or defaults.get(n) for n in key_headers] + [basic.encode() if basic else None]
2541
+
2542
+ # A path relative to the session's base address is decided on the whole address.
2543
+ def whole(self, str_or_url):
1637
2544
  try:
1638
- note(str_or_url, body)
2545
+ return str(self._build_url(str_or_url))
1639
2546
  except Exception:
1640
- pass
2547
+ return str(str_or_url)
2548
+
2549
+ async def requested(self, method, str_or_url, *a, **k):
2550
+ body = k.get("json") if k.get("json") is not None else k.get("data")
2551
+ url = whole(self, str_or_url)
2552
+ if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max)):
2553
+ port = held_port()
2554
+ if port is None:
2555
+ raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
2556
+ parts = urlsplit(url)
2557
+ n = str(next(held_sent))
2558
+ held_waiting[n] = (url, method, ctx.get())
2559
+ k = {**k, "headers": {**dict(k.get("headers") or {}), "x-cortad-held": n}}
2560
+ target = "http://127.0.0.1:%d%s%s" % (port, parts.path or "/", "?" + parts.query if parts.query else "")
2561
+ if getattr(self, "_base_url", None) is None:
2562
+ return await request(self, method, target, *a, **k)
2563
+ # A session with a base address refuses a whole one on older aiohttp: the held call is
2564
+ # answered through a session of its own, read before that session closes.
2565
+ async with module.ClientSession() as own:
2566
+ answered = await request(own, method, target, *a, **k)
2567
+ await answered.read()
2568
+ return answered
2569
+ caller = callers_now(str_or_url) if is_model_call(str_or_url) or is_retrieval(str_or_url) else None
2570
+ t0 = time.time()
2571
+ req = noted(str_or_url, body)
1641
2572
  try:
1642
2573
  response = await request(self, method, str_or_url, *a, **k)
2574
+ answered_call(str_or_url, req)
1643
2575
  except Exception as err:
1644
- if not is_model_call(str_or_url):
1645
- dep(str_or_url, 0, type(err).__name__)
2576
+ if is_model_call(str_or_url):
2577
+ meter(str_or_url, text(body, reply_max), 0, "", "", req, caller, t0)
2578
+ else:
2579
+ dep(str_or_url, 0, type(err).__name__, caller=caller)
1646
2580
  raise
1647
2581
  if is_model_call(str_or_url):
1648
- response._cortad = (str_or_url, text(body), turn_now(), callers_now(str_or_url))
2582
+ response._cortad = (str_or_url, text(body, reply_max), req, caller, t0)
1649
2583
  elif is_retrieval(str_or_url):
1650
- response._cortad_dep = (str_or_url, turn_now())
2584
+ response._cortad_dep = (str_or_url, ctx.get(), caller)
1651
2585
  else:
1652
2586
  dep(str_or_url, response.status)
1653
2587
  return response
@@ -1660,29 +2594,53 @@ def _install():
1660
2594
  store = getattr(self, "_cortad_dep", None)
1661
2595
  if store:
1662
2596
  self._cortad_dep = None
1663
- dep(store[0], self.status, passages=passages_from((raw or b"").decode("utf-8", "replace")), turn=store[1])
2597
+ dep(store[0], self.status, passages=passages_from((raw or b"").decode("utf-8", "replace")), req=store[1], caller=store[2])
1664
2598
  call = getattr(self, "_cortad", None)
1665
2599
  if call:
1666
2600
  self._cortad = None
1667
- meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2], call[3])
2601
+ meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), *call[2:])
1668
2602
  return raw
1669
2603
 
1670
2604
  def release_kept(self, *a, **k):
1671
2605
  store = getattr(self, "_cortad_dep", None)
1672
2606
  if store:
1673
2607
  self._cortad_dep = None
1674
- dep(store[0], self.status, turn=store[1])
2608
+ dep(store[0], self.status, req=store[1], caller=store[2])
1675
2609
  call = getattr(self, "_cortad", None)
1676
2610
  if call:
1677
2611
  self._cortad = None
1678
- meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2], call[3])
2612
+ meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", *call[2:])
1679
2613
  return release(self, *a, **k)
1680
2614
 
1681
2615
  module.ClientSession._request = requested
1682
2616
  module.ClientResponse.read = read_kept
1683
2617
  module.ClientResponse.release = release_kept
1684
2618
 
1685
- exact = {"uvicorn.config": patch_uvicorn, "werkzeug.serving": patch_werkzeug, "django.core.handlers.wsgi": patch_django,
2619
+ # Starlette's streamed response, placed as it sends: behind an HTTP middleware the reply reaches the
2620
+ # server's send on a task of its own, read off a stream this response writes into, by which time
2621
+ # the app's generator has moved on to the next chunk.
2622
+ def patch_starlette_responses(module):
2623
+ call = module.StreamingResponse.__call__
2624
+
2625
+ async def called(self, scope, receive, send):
2626
+ req = ctx.get()
2627
+ if not (sites and req):
2628
+ return await call(self, scope, receive, send)
2629
+ req["placed"] = True
2630
+
2631
+ async def sending(message):
2632
+ if message.get("type") == "http.response.body":
2633
+ try:
2634
+ sites.made(req, self.body_iterator, message.get("body") or b"")
2635
+ except Exception:
2636
+ pass
2637
+ return await send(message)
2638
+
2639
+ return await call(self, scope, receive, sending)
2640
+
2641
+ module.StreamingResponse.__call__ = called
2642
+
2643
+ exact = {"uvicorn.config": patch_uvicorn, "starlette.responses": patch_starlette_responses, "werkzeug.serving": patch_werkzeug, "django.core.handlers.wsgi": patch_django,
1686
2644
  "django.core.servers.basehttp": patch_django_server, "gunicorn.workers.base": patch_gunicorn,
1687
2645
  "hypercorn.asyncio.run": patch_hypercorn, "hypercorn.trio.run": patch_hypercorn, "aiohttp.web": patch_aiohttp_web,
1688
2646
  "requests.sessions": patch_requests, "aiohttp.client": patch_aiohttp}
@@ -1733,11 +2691,62 @@ def _install():
1733
2691
  write({"hello": "python", "pid": os.getpid()})
1734
2692
 
1735
2693
 
2694
+ # Every server the app opens a connection to, once each, by host and port only: a database named in
2695
+ # a config file rather than in the environment is seen the first time the app reaches it. A socket
2696
+ # connects to an address, so the name a client asked for is read where it is resolved. A driver
2697
+ # written in C (psycopg, mysqlclient) opens its own sockets and is not seen here.
2698
+ def _install_connects():
2699
+ import json
2700
+ import socket
2701
+
2702
+ seen = set()
2703
+ real_connect, real_connect_ex = socket.socket.connect, socket.socket.connect_ex
2704
+ real_getaddrinfo = socket.getaddrinfo
2705
+
2706
+ def note(address):
2707
+ try:
2708
+ if not isinstance(address, tuple) or len(address) < 2 or len(seen) >= 64:
2709
+ return
2710
+ host, port = str(address[0]).lower()[:253], int(address[1])
2711
+ if (host, port) in seen or port in _held_ports:
2712
+ return
2713
+ seen.add((host, port))
2714
+ fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
2715
+ with os.fdopen(fd, "a", encoding="utf-8") as f:
2716
+ f.write(json.dumps({"conn": {"host": host, "port": port}}) + "\n")
2717
+ except Exception:
2718
+ pass
2719
+
2720
+ def connect(self, address):
2721
+ note(address)
2722
+ return real_connect(self, address)
2723
+
2724
+ def connect_ex(self, address):
2725
+ note(address)
2726
+ return real_connect_ex(self, address)
2727
+
2728
+ def getaddrinfo(host, port, *args, **kwargs):
2729
+ if isinstance(host, (str, bytes)) and port is not None:
2730
+ try:
2731
+ note((host.decode() if isinstance(host, bytes) else host, int(port)))
2732
+ except (TypeError, ValueError):
2733
+ pass
2734
+ return real_getaddrinfo(host, port, *args, **kwargs)
2735
+
2736
+ socket.socket.connect = connect
2737
+ socket.socket.connect_ex = connect_ex
2738
+ socket.getaddrinfo = getaddrinfo
2739
+
2740
+
1736
2741
  if _FILE:
1737
2742
  try:
1738
2743
  _install()
1739
2744
  except Exception:
1740
2745
  pass
2746
+ try:
2747
+ _install_connects()
2748
+ except Exception:
2749
+ pass
1741
2750
  if _WRITES and _APP_ROOT:
1742
2751
  try:
1743
2752
  _install_writes()