keywright-server 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
keywright_server/ssh.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Runs one command on one host over SSH. Keys live on the Keywright host only.
|
|
2
|
+
|
|
3
|
+
Resource config for kind=ssh:
|
|
4
|
+
{"host": "10.0.0.5", "port": 22, "user": "root", "key_path": "/var/lib/keywright/keys/web1",
|
|
5
|
+
"host_key": "<pinned 'ssh-ed25519 AAAA...' line, learned on first connect>"}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import ipaddress
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import asyncssh
|
|
19
|
+
|
|
20
|
+
MAX_OUTPUT = 1024 * 1024
|
|
21
|
+
MAX_PUT = 1024 * 1024
|
|
22
|
+
CONNECT_TIMEOUT = 30
|
|
23
|
+
MAX_TIMEOUT = 600
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RunResult:
|
|
28
|
+
exit_code: int | None
|
|
29
|
+
stdout: str
|
|
30
|
+
stderr: str
|
|
31
|
+
truncated: bool
|
|
32
|
+
duration_ms: int
|
|
33
|
+
learned_host_key: str | None = None # set when we pinned the host key on this call
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HostKeyMismatch(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _server_key_line(conn) -> str | None:
|
|
41
|
+
"""'ssh-ed25519 AAAA...' for the host key the server proved it holds on this connection."""
|
|
42
|
+
key = conn.get_server_host_key()
|
|
43
|
+
return key.export_public_key().decode().strip() if key is not None else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _known_hosts_for(pinned: str):
|
|
47
|
+
"""asyncssh known_hosts argument that trusts exactly the pinned key (for any hostname)."""
|
|
48
|
+
try:
|
|
49
|
+
return ([asyncssh.import_public_key(pinned)], [], [])
|
|
50
|
+
except asyncssh.KeyImportError as e:
|
|
51
|
+
raise HostKeyMismatch("stored host key is not a valid public key; forget it in the UI and re-pin") from e
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def run(config: dict[str, Any], command: str, *, timeout: int = 300, user: str | None = None,
|
|
55
|
+
stdin: bytes | None = None) -> RunResult:
|
|
56
|
+
timeout = max(1, min(int(timeout or 300), MAX_TIMEOUT))
|
|
57
|
+
host = config["host"]
|
|
58
|
+
port = int(config.get("port") or 22)
|
|
59
|
+
username = user or config.get("user") or "root"
|
|
60
|
+
key_path = config.get("key_path")
|
|
61
|
+
pinned = config.get("host_key")
|
|
62
|
+
started = time.monotonic()
|
|
63
|
+
|
|
64
|
+
options: dict[str, Any] = {
|
|
65
|
+
"host": host,
|
|
66
|
+
"port": port,
|
|
67
|
+
"username": username,
|
|
68
|
+
"client_keys": [key_path] if key_path else [],
|
|
69
|
+
"agent_path": None,
|
|
70
|
+
"config": None,
|
|
71
|
+
"connect_timeout": CONNECT_TIMEOUT,
|
|
72
|
+
}
|
|
73
|
+
if pinned:
|
|
74
|
+
options["known_hosts"] = _known_hosts_for(pinned)
|
|
75
|
+
else:
|
|
76
|
+
options["known_hosts"] = None # first contact: learn and pin below
|
|
77
|
+
|
|
78
|
+
learned = None
|
|
79
|
+
async with asyncssh.connect(**options) as conn:
|
|
80
|
+
if not pinned:
|
|
81
|
+
learned = _server_key_line(conn)
|
|
82
|
+
# Drain both streams concurrently, retaining at most MAX_OUTPUT bytes in total.
|
|
83
|
+
# conn.run() buffers all output before returning, so slicing afterwards is not a memory limit.
|
|
84
|
+
buffers = [bytearray(), bytearray()]
|
|
85
|
+
remaining = MAX_OUTPUT
|
|
86
|
+
truncated = False
|
|
87
|
+
async with conn.create_process(command, encoding=None) as process:
|
|
88
|
+
if stdin:
|
|
89
|
+
process.stdin.write(stdin)
|
|
90
|
+
process.stdin.write_eof()
|
|
91
|
+
|
|
92
|
+
async def drain(reader, target):
|
|
93
|
+
nonlocal remaining, truncated
|
|
94
|
+
while chunk := await reader.read(32768):
|
|
95
|
+
keep = min(len(chunk), remaining)
|
|
96
|
+
target.extend(chunk[:keep])
|
|
97
|
+
remaining -= keep
|
|
98
|
+
truncated |= keep < len(chunk)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
await asyncio.wait_for(asyncio.gather(
|
|
102
|
+
drain(process.stdout, buffers[0]), drain(process.stderr, buffers[1]), process.wait_closed(),
|
|
103
|
+
), timeout=timeout)
|
|
104
|
+
code = process.exit_status
|
|
105
|
+
except TimeoutError:
|
|
106
|
+
process.close()
|
|
107
|
+
code = 124
|
|
108
|
+
buffers[1] = bytearray(f"keywright: command timed out after {timeout}s".encode())
|
|
109
|
+
if len(buffers[0]) + len(buffers[1]) > MAX_OUTPUT:
|
|
110
|
+
del buffers[0][MAX_OUTPUT - len(buffers[1]):]
|
|
111
|
+
truncated = True
|
|
112
|
+
out, err = (bytes(b).decode(errors="replace") for b in buffers)
|
|
113
|
+
return RunResult(
|
|
114
|
+
exit_code=code,
|
|
115
|
+
stdout=out if isinstance(out, str) else out.decode(errors="replace"),
|
|
116
|
+
stderr=err if isinstance(err, str) else err.decode(errors="replace"),
|
|
117
|
+
truncated=truncated,
|
|
118
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
119
|
+
learned_host_key=learned,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def remote_path(path: str) -> str:
|
|
124
|
+
"""An absolute path that can also be spelled as permission-path segments, so a rule can name it
|
|
125
|
+
(ssh/<host>/put/etc/komodo/stacks/**). Anything else is refused rather than escaped."""
|
|
126
|
+
from keywright.patterns import validate
|
|
127
|
+
|
|
128
|
+
if not isinstance(path, str) or not path.startswith("/") or path.endswith("/") or "//" in path:
|
|
129
|
+
raise ValueError("path must be absolute, without a trailing slash or empty segments")
|
|
130
|
+
parts = path[1:].split("/")
|
|
131
|
+
if any(seg in (".", "..") or seg.endswith("*") for seg in parts):
|
|
132
|
+
raise ValueError("path must not contain . or .. segments or end a segment with *")
|
|
133
|
+
try:
|
|
134
|
+
validate("ssh/x/put/" + "/".join(parts))
|
|
135
|
+
except ValueError as e:
|
|
136
|
+
raise ValueError(f"path segments may only use letters, digits and _ . : @ + = , - ({e})") from e
|
|
137
|
+
return path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def put_command(path: str, mode: str) -> str:
|
|
141
|
+
"""Write stdin to `path` atomically: temp file beside it, mode set, then renamed into place, so a
|
|
142
|
+
dropped connection never leaves a half-written file. Parent directories are created."""
|
|
143
|
+
if not re.fullmatch(r"0?[0-7]{3}", mode):
|
|
144
|
+
raise ValueError("mode must be three octal digits, e.g. 0644")
|
|
145
|
+
script = ('set -e; f=$1; d=$(dirname -- "$f"); mkdir -p -- "$d"; t=$(mktemp -- "$d/.keywright.XXXXXX"); '
|
|
146
|
+
'cat > "$t"; chmod "$2" "$t"; mv -f -- "$t" "$f"')
|
|
147
|
+
return f"sh -c {shlex.quote(script)} keywright {shlex.quote(path)} {mode}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def append_command(path: str) -> str:
|
|
151
|
+
"""Append stdin to `path`, creating it (and its directory) if needed, and never widening its mode."""
|
|
152
|
+
script = 'set -e; f=$1; mkdir -p -- "$(dirname -- "$f")"; [ -e "$f" ] || (umask 077; : > "$f"); cat >> "$f"'
|
|
153
|
+
return f"sh -c {shlex.quote(script)} keywright {shlex.quote(path)}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_command(path: str) -> str:
|
|
157
|
+
return f"cat -- {shlex.quote(path)}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def failure_stage(exc: BaseException) -> tuple[str, str]:
|
|
161
|
+
"""Where a run broke, coarse enough to hold no secrets: (stage, what the agent should do)."""
|
|
162
|
+
if isinstance(exc, asyncssh.PermissionDenied):
|
|
163
|
+
return "auth", "The host rejected Keywright's key; the admin has to enrol it on the host."
|
|
164
|
+
if isinstance(exc, (asyncssh.HostKeyNotVerifiable, HostKeyMismatch)):
|
|
165
|
+
return "hostkey", "The host key does not match the pinned one; the admin has to re-probe the resource."
|
|
166
|
+
if isinstance(exc, (OSError, TimeoutError, asyncssh.ConnectionLost, asyncssh.DisconnectError)):
|
|
167
|
+
return "unreachable", "No SSH connection to the host; check that it is up and the port is open."
|
|
168
|
+
return "exec", "The connection worked but the command could not be run."
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def probe(config: dict[str, Any]) -> tuple[bool, str, str | None]:
|
|
172
|
+
"""Connection test used by the admin UI. Returns (ok, message, learned_host_key)."""
|
|
173
|
+
try:
|
|
174
|
+
r = await run(config, "echo keywright-ok", timeout=15)
|
|
175
|
+
except asyncssh.PermissionDenied:
|
|
176
|
+
return False, "Authentication failed: is the key authorized on the host?", None
|
|
177
|
+
except asyncssh.HostKeyNotVerifiable as e:
|
|
178
|
+
return False, f"Host key mismatch: {e}", None
|
|
179
|
+
except HostKeyMismatch as e:
|
|
180
|
+
return False, str(e), None
|
|
181
|
+
except (OSError, asyncssh.Error, TimeoutError) as e:
|
|
182
|
+
return False, f"{type(e).__name__}: {e}", None
|
|
183
|
+
if r.exit_code == 0 and "keywright-ok" in r.stdout:
|
|
184
|
+
return True, f"Connected as {config.get('user') or 'root'}@{config['host']} in {r.duration_ms} ms", r.learned_host_key
|
|
185
|
+
return False, f"Unexpected reply (exit {r.exit_code}): {r.stderr[:200]}", r.learned_host_key
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---- key generation & enrolment -----------------------------------------------------------------
|
|
189
|
+
def generate_key(name: str, keys_dir, comment: str | None = None) -> tuple[str, str]:
|
|
190
|
+
"""Create an ed25519 keypair under keys_dir/<name>. Returns (private_path, public_line)."""
|
|
191
|
+
from pathlib import Path
|
|
192
|
+
|
|
193
|
+
keys_dir = Path(keys_dir)
|
|
194
|
+
keys_dir.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
priv = keys_dir / name
|
|
196
|
+
if priv.exists():
|
|
197
|
+
raise FileExistsError(f"{priv} already exists")
|
|
198
|
+
key = asyncssh.generate_private_key("ssh-ed25519", comment=comment or f"keywright->{name}")
|
|
199
|
+
import os
|
|
200
|
+
|
|
201
|
+
fd = os.open(priv, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
202
|
+
with os.fdopen(fd, "wb") as out:
|
|
203
|
+
out.write(key.export_private_key())
|
|
204
|
+
pub_line = key.export_public_key().decode().strip()
|
|
205
|
+
(keys_dir / f"{name}.pub").write_text(pub_line + "\n")
|
|
206
|
+
return str(priv), pub_line
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def authorized_keys_line(pub_line: str, restrict_from: str | None) -> str:
|
|
210
|
+
if restrict_from:
|
|
211
|
+
# This setting represents one outbound address, not arbitrary authorized_keys options.
|
|
212
|
+
restrict_from = str(ipaddress.ip_address(restrict_from))
|
|
213
|
+
if "\n" in pub_line or "\r" in pub_line:
|
|
214
|
+
raise ValueError("Public key must be a single line")
|
|
215
|
+
return (f'from="{restrict_from}" ' if restrict_from else "") + pub_line
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def enroll_script(pub_line: str, restrict_from: str | None) -> str:
|
|
219
|
+
"""POSIX shell that installs the key for the invoking user, idempotently."""
|
|
220
|
+
line = authorized_keys_line(pub_line, restrict_from)
|
|
221
|
+
key_part = pub_line.split()[1]
|
|
222
|
+
return (
|
|
223
|
+
"set -e\n"
|
|
224
|
+
'D="$HOME/.ssh"; F="$D/authorized_keys"\n'
|
|
225
|
+
'mkdir -p "$D" && chmod 700 "$D"\n'
|
|
226
|
+
f"if grep -qF {shlex.quote(key_part)} \"$F\" 2>/dev/null; then echo 'keywright: key already present'; "
|
|
227
|
+
f"else printf '%s\\n' {shlex.quote(line)} >> \"$F\" && echo 'keywright: key installed for' \"$(id -un)@$(hostname)\"; fi\n"
|
|
228
|
+
'chmod 600 "$F"\n'
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def enroll_with_password(config: dict[str, Any], password: str, pub_line: str, restrict: bool = True,
|
|
233
|
+
*, login_user: str | None = None, escalate: str | None = None,
|
|
234
|
+
root_password: str | None = None) -> tuple[bool, str, str | None]:
|
|
235
|
+
"""Log in once with a password, install the public key for the resource user, learn the host key.
|
|
236
|
+
|
|
237
|
+
login_user: account to log in as when password SSH is disabled for the target user (e.g. root).
|
|
238
|
+
escalate: how to become the target user from login_user: "sudo" (uses root_password or the
|
|
239
|
+
login password) or "su" (uses root_password). None when login_user is the target.
|
|
240
|
+
Passwords are used for this connection only and never stored. Returns (ok, message, host_key)."""
|
|
241
|
+
import base64
|
|
242
|
+
import shlex
|
|
243
|
+
|
|
244
|
+
host = config["host"]
|
|
245
|
+
port = int(config.get("port") or 22)
|
|
246
|
+
target = config.get("user") or "root"
|
|
247
|
+
login = (login_user or "").strip() or target
|
|
248
|
+
|
|
249
|
+
def safe_output(value: str) -> str:
|
|
250
|
+
for secret in (password, root_password):
|
|
251
|
+
if secret:
|
|
252
|
+
value = value.replace(secret, "[redacted]")
|
|
253
|
+
return value
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
async with asyncssh.connect(host, port=port, username=login, password=password,
|
|
257
|
+
known_hosts=_known_hosts_for(config["host_key"]) if config.get("host_key") else None,
|
|
258
|
+
client_keys=[], agent_path=None, config=None, connect_timeout=CONNECT_TIMEOUT) as conn:
|
|
259
|
+
learned = _server_key_line(conn)
|
|
260
|
+
src = conn.get_extra_info("sockname")[0] if restrict else None
|
|
261
|
+
script = enroll_script(pub_line, src)
|
|
262
|
+
if login == target:
|
|
263
|
+
res = await asyncio.wait_for(conn.run(script, check=False), timeout=30)
|
|
264
|
+
else:
|
|
265
|
+
b64 = base64.b64encode(script.encode()).decode()
|
|
266
|
+
inner = f"echo {b64} | base64 -d | sh"
|
|
267
|
+
if escalate == "su":
|
|
268
|
+
cmd = f"su - {shlex.quote(target)} -c {shlex.quote(inner)}"
|
|
269
|
+
res = await asyncio.wait_for(
|
|
270
|
+
conn.run(cmd, input=(root_password or "") + "\n", term_type="xterm", check=False), timeout=45)
|
|
271
|
+
elif escalate == "sudo":
|
|
272
|
+
cmd = f"sudo -H -S -p '' -u {shlex.quote(target)} sh -c {shlex.quote(inner)}"
|
|
273
|
+
res = await asyncio.wait_for(
|
|
274
|
+
conn.run(cmd, input=(root_password or password) + "\n", check=False), timeout=45)
|
|
275
|
+
else:
|
|
276
|
+
return False, f"Logged in as {login}, but no escalation method chosen to become {target}.", learned
|
|
277
|
+
out = safe_output(res.stdout or "").replace("\r", "")
|
|
278
|
+
if res.exit_status != 0:
|
|
279
|
+
err = safe_output(res.stderr or "")[:300] or out[-300:]
|
|
280
|
+
return False, f"install script failed (exit {res.exit_status}): {err.strip()}", learned
|
|
281
|
+
note = next((ln.strip() for ln in out.splitlines() if "keywright:" in ln), out.strip()[-200:])
|
|
282
|
+
except asyncssh.PermissionDenied:
|
|
283
|
+
return False, f"Password rejected for {login}@{host} (or password logins are disabled for that user).", None
|
|
284
|
+
except (OSError, asyncssh.Error, TimeoutError, HostKeyMismatch) as e:
|
|
285
|
+
return False, f"{type(e).__name__}: {e}", None
|
|
286
|
+
ok, msg, _ = await probe({**config, "host_key": learned} if learned else config)
|
|
287
|
+
if not ok:
|
|
288
|
+
return False, f"{note}; but key login as {target} failed afterwards: {msg}", learned
|
|
289
|
+
tail = f" Restricted to source {src}." if restrict else ""
|
|
290
|
+
return True, f"{note}; key login verified ({msg}).{tail}", learned
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""The Access page and the tool watch: what an MCP server offers, what each agent may do with it,
|
|
2
|
+
and what changed upstream since the owner last looked.
|
|
3
|
+
|
|
4
|
+
Rows are built from the live tool list (or a gateway's models). Each row has one rule path, the
|
|
5
|
+
tool's path narrowed by an optional scope such as `account_name=agents` where the tool takes that
|
|
6
|
+
argument, and a state per agent: allow, ask (no rule; the resource default decides) or deny.
|
|
7
|
+
Saving writes only the difference, as ordinary standing rules, and touches no rule it did not
|
|
8
|
+
make the exact shape of: qualified or constrained rules made elsewhere are shown, not edited.
|
|
9
|
+
|
|
10
|
+
The watch keeps a fingerprint per tool (its input schema and annotations) in the resource
|
|
11
|
+
config. A new tool is harmless on its own (no rule covers it, so it asks); a changed one can
|
|
12
|
+
matter: an argument added to an allowed tool is not qualified by any argument key the owner set,
|
|
13
|
+
so it passes on the tool's rule until the owner looks. Drift is recorded and shown, never acted on.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from keywright.patterns import matches, qualifier
|
|
25
|
+
|
|
26
|
+
from . import bundles
|
|
27
|
+
|
|
28
|
+
GROUPS = ("read-only", "write", "destructive", "gone")
|
|
29
|
+
STATES = ("allow", "ask", "deny")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- tools -------------------------------------------------------------------------------------
|
|
33
|
+
def group_of(tool: Any) -> str:
|
|
34
|
+
"""The server's own annotations first (MCP readOnlyHint / destructiveHint), the verb in the
|
|
35
|
+
name when it says nothing."""
|
|
36
|
+
ann = getattr(tool, "annotations", None)
|
|
37
|
+
if ann is not None:
|
|
38
|
+
if getattr(ann, "read_only_hint", None) is True:
|
|
39
|
+
return "read-only"
|
|
40
|
+
if getattr(ann, "destructive_hint", None) is True:
|
|
41
|
+
return "destructive"
|
|
42
|
+
if getattr(ann, "read_only_hint", None) is False and getattr(ann, "destructive_hint", None) is False:
|
|
43
|
+
return "write"
|
|
44
|
+
bucket = next(iter(bundles.suggest([tool.name])))
|
|
45
|
+
return {"read-only": "read-only", "destructive": "destructive"}.get(bucket, "write")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _schema(tool: Any) -> dict[str, Any]:
|
|
49
|
+
return dict(getattr(tool, "input_schema", None) or getattr(tool, "inputSchema", None) or {})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def arguments(tool: Any) -> list[str]:
|
|
53
|
+
return sorted((_schema(tool).get("properties") or {}).keys())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def takes(tool: Any, key: str) -> bool:
|
|
57
|
+
"""Whether a dotted argument key exists in the tool's input schema."""
|
|
58
|
+
node: Any = _schema(tool)
|
|
59
|
+
for part in key.split("."):
|
|
60
|
+
props = node.get("properties") if isinstance(node, dict) else None
|
|
61
|
+
if not isinstance(props, dict) or part not in props:
|
|
62
|
+
return False
|
|
63
|
+
node = props[part]
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fingerprint(tool: Any) -> dict[str, Any]:
|
|
68
|
+
ann = getattr(tool, "annotations", None)
|
|
69
|
+
body = json.dumps({"schema": _schema(tool), "annotations": ann.model_dump(exclude_none=True) if ann else None},
|
|
70
|
+
sort_keys=True, default=str)
|
|
71
|
+
return {"hash": hashlib.sha256(body.encode()).hexdigest()[:16], "args": arguments(tool)}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Drift:
|
|
76
|
+
added: list[str] = field(default_factory=list)
|
|
77
|
+
removed: list[str] = field(default_factory=list)
|
|
78
|
+
changed: list[dict[str, Any]] = field(default_factory=list) # {tool, new_args, dropped_args}
|
|
79
|
+
|
|
80
|
+
def __bool__(self) -> bool:
|
|
81
|
+
return bool(self.added or self.removed or self.changed)
|
|
82
|
+
|
|
83
|
+
def as_dict(self) -> dict[str, Any]:
|
|
84
|
+
return {"added": self.added, "removed": self.removed, "changed": self.changed}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def diff(seen: dict[str, dict[str, Any]], tools: list[Any]) -> Drift:
|
|
88
|
+
now_fp = {t.name: fingerprint(t) for t in tools}
|
|
89
|
+
d = Drift(added=sorted(set(now_fp) - set(seen)), removed=sorted(set(seen) - set(now_fp)))
|
|
90
|
+
for name in sorted(set(now_fp) & set(seen)):
|
|
91
|
+
old, new = seen[name], now_fp[name]
|
|
92
|
+
if old.get("hash") != new["hash"]:
|
|
93
|
+
d.changed.append({"tool": name,
|
|
94
|
+
"new_args": sorted(set(new["args"]) - set(old.get("args") or [])),
|
|
95
|
+
"dropped_args": sorted(set(old.get("args") or []) - set(new["args"]))})
|
|
96
|
+
return d
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def baseline(tools: list[Any]) -> dict[str, Any]:
|
|
100
|
+
return {"tools_seen": {t.name: fingerprint(t) for t in tools},
|
|
101
|
+
"tools_seen_at": datetime.now(UTC).isoformat(timespec="seconds"), "tools_drift": None}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def record(config: dict[str, Any], tools: list[Any]) -> tuple[dict[str, Any], Drift | None]:
|
|
105
|
+
"""The resource config after a watch pass: the first pass takes a baseline silently; later ones
|
|
106
|
+
store the drift against it (or clear it) and leave the baseline for the owner to accept."""
|
|
107
|
+
seen = config.get("tools_seen")
|
|
108
|
+
if seen is None:
|
|
109
|
+
return {**config, **baseline(tools)}, None
|
|
110
|
+
d = diff(seen, tools)
|
|
111
|
+
stored = {**d.as_dict(), "at": datetime.now(UTC).isoformat(timespec="seconds")} if d else None
|
|
112
|
+
return {**config, "tools_drift": stored}, d
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---- rows and rules ----------------------------------------------------------------------------
|
|
116
|
+
@dataclass
|
|
117
|
+
class Row:
|
|
118
|
+
name: str
|
|
119
|
+
group: str
|
|
120
|
+
pattern: str
|
|
121
|
+
description: str = ""
|
|
122
|
+
state: str = "ask"
|
|
123
|
+
everyone: str | None = None # an every-agent rule on the same path, when viewing one agent
|
|
124
|
+
other_rules: int = 0 # rules on this tool of another shape (qualified, constrained)
|
|
125
|
+
covered_by: list[str] = field(default_factory=list) # broader rules that already decide this row
|
|
126
|
+
flag: str | None = None # new | changed | gone
|
|
127
|
+
new_args: list[str] = field(default_factory=list)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def scoped(base: str, scope: tuple[str, str] | None, tool: Any | None) -> str:
|
|
131
|
+
if scope and tool is not None and takes(tool, scope[0]):
|
|
132
|
+
return f"{base}/{qualifier(scope[0], scope[1])}"
|
|
133
|
+
return base
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_scope(text: str, arg_keys: list[str]) -> tuple[str, str] | None:
|
|
137
|
+
text = (text or "").strip()
|
|
138
|
+
if not text:
|
|
139
|
+
return None
|
|
140
|
+
if "=" not in text:
|
|
141
|
+
raise ValueError("Scope is key=value, e.g. account_name=agents")
|
|
142
|
+
key, value = (s.strip() for s in text.split("=", 1))
|
|
143
|
+
if key not in arg_keys:
|
|
144
|
+
raise ValueError(f"{key!r} is not one of this server's argument keys ({', '.join(arg_keys) or 'none set'}); "
|
|
145
|
+
"add it on the resource first, or rules on it would never match")
|
|
146
|
+
if not value or "*" in value:
|
|
147
|
+
raise ValueError("Scope needs a literal value")
|
|
148
|
+
return key, value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def state_of(policies: list[Any], pattern: str, agent_id: str | None) -> str:
|
|
152
|
+
exact = [p for p in policies if p.pattern == pattern and not p.constraints and p.agent_id == agent_id]
|
|
153
|
+
if any(p.effect == "deny" for p in exact):
|
|
154
|
+
return "deny"
|
|
155
|
+
return "allow" if exact else "ask"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def touches(policy_pattern: str, tool_path: str) -> bool:
|
|
159
|
+
return policy_pattern == tool_path or policy_pattern.startswith(tool_path + "/")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def mcp_rows(res: Any, tools: list[Any], policies: list[Any], agent_id: str | None,
|
|
163
|
+
scope: tuple[str, str] | None) -> list[Row]:
|
|
164
|
+
seen = res.config.get("tools_seen") or {}
|
|
165
|
+
drift = res.config.get("tools_drift") or {}
|
|
166
|
+
changed = {c["tool"]: c for c in drift.get("changed") or []}
|
|
167
|
+
rows = []
|
|
168
|
+
for t in sorted(tools, key=lambda t: t.name):
|
|
169
|
+
base = f"mcp/{res.name}/tools/{t.name}"
|
|
170
|
+
rows.append(_row(t.name, group_of(t), scoped(base, scope, t), base, policies, agent_id,
|
|
171
|
+
(t.description or "").strip().splitlines()[0][:160] if t.description else "",
|
|
172
|
+
flag="new" if seen and t.name not in seen else "changed" if t.name in changed else None,
|
|
173
|
+
new_args=(changed.get(t.name) or {}).get("new_args") or []))
|
|
174
|
+
# Rules on tools the server no longer offers: one row per rule, on its exact path, so Ask
|
|
175
|
+
# removes it whatever its shape.
|
|
176
|
+
live = {t.name for t in tools}
|
|
177
|
+
prefix = f"mcp/{res.name}/tools/"
|
|
178
|
+
stale = sorted({p.pattern for p in policies if p.agent_id == agent_id and not p.constraints
|
|
179
|
+
and p.pattern.startswith(prefix)
|
|
180
|
+
and p.pattern[len(prefix):].split("/", 1)[0] not in live | {"*", "**"}})
|
|
181
|
+
for pattern in stale:
|
|
182
|
+
rows.append(_row(pattern[len(prefix):], "gone", pattern, pattern, policies, agent_id,
|
|
183
|
+
"No longer offered by the server; this rule can go.", flag="gone"))
|
|
184
|
+
return rows
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def llm_rows(res: Any, model_paths: dict[str, str], policies: list[Any], agent_id: str | None) -> list[Row]:
|
|
188
|
+
return [_row(m, "models", path, path, policies, agent_id, "") for m, path in model_paths.items()]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _row(name, group, pattern, base, policies, agent_id, description, flag=None, new_args=None) -> Row:
|
|
192
|
+
mine = [p for p in policies if p.agent_id == agent_id]
|
|
193
|
+
# Rules that match this row's path without being its own: a wildcard or a shorter prefix. They
|
|
194
|
+
# decide the call whatever this row says (a deny anywhere wins), so the page says so.
|
|
195
|
+
covered = [f"{p.effect} {p.pattern}{'' if p.agent_id else ' (every agent)'}" for p in policies
|
|
196
|
+
if (p.agent_id in (agent_id, None)) and not p.constraints and p.pattern != pattern
|
|
197
|
+
and matches(p.pattern, pattern)]
|
|
198
|
+
return Row(covered_by=covered, name=name, group=group, pattern=pattern, description=description,
|
|
199
|
+
state=state_of(policies, pattern, agent_id),
|
|
200
|
+
everyone=(state_of(policies, pattern, None) if agent_id else None),
|
|
201
|
+
other_rules=sum(1 for p in mine if touches(p.pattern, base) and (p.pattern != pattern or p.constraints)),
|
|
202
|
+
flag=flag, new_args=new_args or [])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class Plan:
|
|
207
|
+
add: list[tuple[str, str]] = field(default_factory=list) # (pattern, effect)
|
|
208
|
+
delete: list[Any] = field(default_factory=list) # Policy rows
|
|
209
|
+
|
|
210
|
+
def __bool__(self) -> bool:
|
|
211
|
+
return bool(self.add or self.delete)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def plan(rows: list[Row], wanted: dict[str, str], policies: list[Any], agent_id: str | None) -> Plan:
|
|
215
|
+
"""The rule changes that take each row from its state to the wanted one. Rows not in `wanted`
|
|
216
|
+
(not on the submitted form) are left alone."""
|
|
217
|
+
out = Plan()
|
|
218
|
+
for row in rows:
|
|
219
|
+
want = wanted.get(row.name)
|
|
220
|
+
if want not in STATES or want == row.state:
|
|
221
|
+
continue
|
|
222
|
+
out.delete += [p for p in policies if p.pattern == row.pattern and not p.constraints
|
|
223
|
+
and p.agent_id == agent_id]
|
|
224
|
+
if want != "ask":
|
|
225
|
+
out.add.append((row.pattern, want))
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---- the watch -----------------------------------------------------------------------------------
|
|
230
|
+
async def watch_once(db) -> list[tuple[str, Drift]]:
|
|
231
|
+
"""One pass over every MCP server: baseline the new ones, record drift on the rest. A server
|
|
232
|
+
that cannot be reached is skipped (its last drift stands). Returns the servers whose drift is
|
|
233
|
+
new since the last pass, which are also written to the audit log."""
|
|
234
|
+
import logging
|
|
235
|
+
|
|
236
|
+
from sqlalchemy import select
|
|
237
|
+
|
|
238
|
+
from . import audit, mcp_proxy
|
|
239
|
+
from .models import Resource
|
|
240
|
+
|
|
241
|
+
log = logging.getLogger("keywright.tools")
|
|
242
|
+
fresh: list[tuple[str, Drift]] = []
|
|
243
|
+
for res in (await db.execute(select(Resource).where(Resource.kind == "mcp"))).scalars().all():
|
|
244
|
+
try:
|
|
245
|
+
tools = await mcp_proxy.list_tools(res.name, res.config, force=True)
|
|
246
|
+
except Exception as e: # noqa: BLE001
|
|
247
|
+
log.info("tool watch: %s unreachable: %s", res.name, mcp_proxy.describe(e))
|
|
248
|
+
continue
|
|
249
|
+
before = (res.config.get("tools_drift") or {})
|
|
250
|
+
config, d = record(res.config, tools)
|
|
251
|
+
if config == res.config:
|
|
252
|
+
continue
|
|
253
|
+
res.config = config
|
|
254
|
+
await db.commit()
|
|
255
|
+
if d and {k: before.get(k) for k in ("added", "removed", "changed")} != d.as_dict():
|
|
256
|
+
fresh.append((res.name, d))
|
|
257
|
+
log.warning("tool watch: %s changed: %s", res.name, d.as_dict())
|
|
258
|
+
await audit.record(db, tool="tools.drift", decision="changed",
|
|
259
|
+
detail={"resource": res.name, **d.as_dict()})
|
|
260
|
+
return fresh
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Usage totals from local proxy audit events, with independent provider identities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
from .models import AuditEvent, LlmKey, Resource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def count(value):
|
|
14
|
+
return value if type(value) is int and 0 <= value else None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def token_counts(usage):
|
|
18
|
+
usage = usage if isinstance(usage, dict) else {}
|
|
19
|
+
incoming = count(usage.get("prompt", usage.get("input")))
|
|
20
|
+
outgoing = count(usage.get("completion", usage.get("output")))
|
|
21
|
+
# Anthropic cache counts are additional input; OpenAI cached tokens are a subset.
|
|
22
|
+
if incoming is not None and "prompt" not in usage:
|
|
23
|
+
incoming += (count(usage.get("cache_creation_input")) or 0)
|
|
24
|
+
incoming += (count(usage.get("cache_read_input")) or 0)
|
|
25
|
+
total = count(usage.get("total"))
|
|
26
|
+
if total is None and (incoming is not None or outgoing is not None):
|
|
27
|
+
total = (incoming or 0) + (outgoing or 0)
|
|
28
|
+
return incoming, outgoing, total
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Totals:
|
|
33
|
+
requests: int = 0
|
|
34
|
+
errors: int = 0
|
|
35
|
+
reported: int = 0
|
|
36
|
+
unreported: int = 0
|
|
37
|
+
partial: int = 0
|
|
38
|
+
input: int = 0
|
|
39
|
+
output: int = 0
|
|
40
|
+
total: int = 0
|
|
41
|
+
|
|
42
|
+
def add(self, event):
|
|
43
|
+
incoming, outgoing, total = token_counts(event.detail.get("usage"))
|
|
44
|
+
self.requests += 1
|
|
45
|
+
self.errors += event.decision == "error"
|
|
46
|
+
self.reported += total is not None
|
|
47
|
+
self.unreported += total is None
|
|
48
|
+
self.partial += total is not None and (
|
|
49
|
+
incoming is None or outgoing is None or event.detail.get("response_complete") is False)
|
|
50
|
+
self.input += incoming or 0
|
|
51
|
+
self.output += outgoing or 0
|
|
52
|
+
self.total += total or 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def report(db, start: datetime, end: datetime, *, provider="", model="", agent="", key=""):
|
|
56
|
+
# Only select safe identity fields, never load upstream secrets into the report.
|
|
57
|
+
resources = (await db.execute(select(Resource.id, Resource.name).where(Resource.kind == "llm"))).all()
|
|
58
|
+
resource_names = dict(resources)
|
|
59
|
+
resource_ids = {name: rid for rid, name in resources}
|
|
60
|
+
aliases = {}
|
|
61
|
+
for kid, rid, aid, alias in (await db.execute(select(
|
|
62
|
+
LlmKey.id, LlmKey.resource_id, LlmKey.agent_id, LlmKey.key_alias))).all():
|
|
63
|
+
identity = (rid, aid, alias)
|
|
64
|
+
# Ambiguous historical aliases must stay separate from known key IDs.
|
|
65
|
+
aliases[identity] = kid if identity not in aliases else None
|
|
66
|
+
|
|
67
|
+
options = {name: {} for name in ("provider", "model", "agent", "key")}
|
|
68
|
+
providers, models, keys, days = {}, {}, {}, {}
|
|
69
|
+
summary = Totals()
|
|
70
|
+
recent = []
|
|
71
|
+
stmt = select(AuditEvent).where(
|
|
72
|
+
AuditEvent.tool == "llm.call", AuditEvent.ts >= start, AuditEvent.ts < end,
|
|
73
|
+
AuditEvent.decision.in_(["allow", "error"])
|
|
74
|
+
).order_by(AuditEvent.ts.desc(), AuditEvent.id.desc()).execution_options(yield_per=500)
|
|
75
|
+
async for event in (await db.stream_scalars(stmt)):
|
|
76
|
+
detail = event.detail
|
|
77
|
+
# Exclude discovery, blocked calls, and key-mint failures before an upstream attempt.
|
|
78
|
+
if not detail.get("model") or not (detail.get("gateway_key_id") or detail.get("key_alias")):
|
|
79
|
+
continue
|
|
80
|
+
gateway = detail.get("gateway") or "Unknown provider"
|
|
81
|
+
pid = detail.get("resource_id") or resource_ids.get(gateway) or "legacy:" + gateway
|
|
82
|
+
provider_name = resource_names.get(pid, gateway)
|
|
83
|
+
model_name = detail["model"]
|
|
84
|
+
aid = event.agent_id or "legacy:" + event.agent_name
|
|
85
|
+
agent_name = event.agent_name or "Unknown agent"
|
|
86
|
+
alias = detail.get("key_alias") or "Unidentified key"
|
|
87
|
+
kid = (detail.get("gateway_key_id") or aliases.get((pid, event.agent_id, alias))
|
|
88
|
+
or "legacy:" + alias)
|
|
89
|
+
# A key selector includes its provider and agent, including for legacy aliases.
|
|
90
|
+
key_identity = json.dumps([pid, aid, kid], separators=(",", ":"))
|
|
91
|
+
labels = {"provider": (pid, provider_name), "model": (model_name, model_name),
|
|
92
|
+
"agent": (aid, agent_name),
|
|
93
|
+
"key": (key_identity, f"{alias} · {provider_name} · {agent_name} · {kid}")}
|
|
94
|
+
for dimension, (value, label) in labels.items():
|
|
95
|
+
options[dimension][value] = label
|
|
96
|
+
if any(selected and labels[dimension][0] != selected for dimension, selected in (
|
|
97
|
+
("provider", provider), ("model", model), ("agent", agent), ("key", key))):
|
|
98
|
+
continue
|
|
99
|
+
summary.add(event)
|
|
100
|
+
p = providers.setdefault(pid, {"id": pid, "name": provider_name, "totals": Totals(), "models": {}})
|
|
101
|
+
p["totals"].add(event)
|
|
102
|
+
m = p["models"].setdefault(model_name, {"name": model_name, "totals": Totals()})
|
|
103
|
+
m["totals"].add(event)
|
|
104
|
+
models.setdefault(model_name, Totals()).add(event)
|
|
105
|
+
k = keys.setdefault(key_identity, {"id": key_identity, "alias": alias, "key_id": kid,
|
|
106
|
+
"provider": provider_name, "agent": agent_name,
|
|
107
|
+
"totals": Totals(), "models": set()})
|
|
108
|
+
k["totals"].add(event)
|
|
109
|
+
k["models"].add(model_name)
|
|
110
|
+
day = event.ts.date().isoformat()
|
|
111
|
+
days.setdefault(day, Totals()).add(event)
|
|
112
|
+
if len(recent) < 50:
|
|
113
|
+
recent.append({"id": event.id, "ts": event.ts, "provider": provider_name, "model": model_name,
|
|
114
|
+
"agent": agent_name, "alias": alias, "decision": event.decision,
|
|
115
|
+
"total": token_counts(detail.get("usage"))[2]})
|
|
116
|
+
ranked = lambda row: (-row["totals"].total, -row["totals"].requests)
|
|
117
|
+
return {"totals": summary, "providers": sorted(providers.values(), key=ranked),
|
|
118
|
+
"models": sorted(models.items(), key=lambda row: (-row[1].total, -row[1].requests)),
|
|
119
|
+
"keys": sorted(keys.values(), key=ranked), "days": sorted(days.items()), "recent": recent,
|
|
120
|
+
"options": {name: sorted(values.items(), key=lambda pair: pair[1].lower())
|
|
121
|
+
for name, values in options.items()}}
|
|
File without changes
|