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.
- package/README.md +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/calls.mjs +23 -0
- package/lib/cli.mjs +115 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +52 -18
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1175 -166
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +464 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +155 -38
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +31 -1
- package/skill/SKILL.md +49 -42
- 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
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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)(?:/|$)|\.(?:
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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"))
|
|
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
|
-
#
|
|
578
|
-
#
|
|
579
|
-
#
|
|
580
|
-
#
|
|
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
|
|
585
|
-
# "
|
|
586
|
-
inline = re.compile(r"^\s*(?:[-*]\s+)?([A-Za-z
|
|
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*(?:```|~~~)"
|
|
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
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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
|
|
808
|
+
if heading.match(head) and is_label(head):
|
|
632
809
|
block = ["\n".join(lines[1:])]
|
|
633
|
-
fenced =
|
|
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
|
|
814
|
+
if fences(paras[i]) % 2 == 1:
|
|
638
815
|
fenced = not fenced
|
|
639
|
-
|
|
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
|
-
|
|
642
|
-
if
|
|
643
|
-
add(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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[:
|
|
1155
|
+
return bytes(body[:most]).decode("utf-8", "replace")
|
|
799
1156
|
if isinstance(body, str):
|
|
800
|
-
return body[:
|
|
1157
|
+
return body[:most]
|
|
801
1158
|
try:
|
|
802
|
-
return json.dumps(body)[:
|
|
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
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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,
|
|
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": [
|
|
1045
|
-
|
|
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
|
-
|
|
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,
|
|
1857
|
+
tool_row(name, sig, a, k, None, e, req)
|
|
1065
1858
|
raise
|
|
1066
|
-
tool_row(name, sig, a, k, out, None,
|
|
1859
|
+
tool_row(name, sig, a, k, out, None, req)
|
|
1067
1860
|
return out
|
|
1068
1861
|
else:
|
|
1069
1862
|
def wrapped(*a, **k):
|
|
1070
|
-
|
|
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,
|
|
1867
|
+
tool_row(name, sig, a, k, None, e, req)
|
|
1075
1868
|
raise
|
|
1076
|
-
tool_row(name, sig, a, k, out, None,
|
|
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
|
-
|
|
1168
|
-
|
|
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
|
-
#
|
|
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 ("
|
|
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
|
-
|
|
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,
|
|
2022
|
+
return await self.app(scope, seen if asks else receive, sending)
|
|
1196
2023
|
finally:
|
|
1197
|
-
|
|
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
|
-
|
|
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 ("
|
|
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
|
-
|
|
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
|
-
|
|
2121
|
+
out = app(environ, starting)
|
|
2122
|
+
except BaseException:
|
|
2123
|
+
ended(req)
|
|
2124
|
+
raise
|
|
1258
2125
|
finally:
|
|
1259
|
-
|
|
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
|
|
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")),
|
|
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"))),
|
|
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
|
|
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
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
|
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
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
|
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
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
|
1616
|
-
|
|
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
|
|
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
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
2545
|
+
return str(self._build_url(str_or_url))
|
|
1639
2546
|
except Exception:
|
|
1640
|
-
|
|
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
|
|
1645
|
-
|
|
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
|
|
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,
|
|
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")),
|
|
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]
|
|
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,
|
|
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]
|
|
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
|
-
|
|
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()
|