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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Replaceable retrieval indexes. Index hits are IDs, never authoritative document content.
|
|
2
|
+
|
|
3
|
+
The gateway rechecks collection, publication, expiry and version before returning any hit.
|
|
4
|
+
PostgreSQL is an optional, disposable index, independent of Keywright's broker database.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from time import perf_counter
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from sqlalchemy import text
|
|
19
|
+
|
|
20
|
+
from .config import env
|
|
21
|
+
|
|
22
|
+
# Grammatical words otherwise dominate OR-based question retrieval after sections get smaller.
|
|
23
|
+
# Keep negation and domain terms. If a query consists only of these words, search it literally.
|
|
24
|
+
QUESTION_WORDS = frozenset({
|
|
25
|
+
"a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "could", "did",
|
|
26
|
+
"do", "does", "for", "from", "had", "has", "have", "how", "i", "if", "in", "into", "is",
|
|
27
|
+
"it", "its", "may", "me", "my", "of", "on", "or", "our", "should", "than", "that", "the",
|
|
28
|
+
"their", "them", "then", "there", "these", "they", "this", "those", "to", "was", "we",
|
|
29
|
+
"were", "what", "when", "where", "which", "who", "why", "will", "with", "would", "you", "your",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def search_terms(query):
|
|
34
|
+
words = re.findall(r"\w+", query, flags=re.UNICODE)
|
|
35
|
+
meaningful = [word for word in words if word.casefold() not in QUESTION_WORDS]
|
|
36
|
+
return (meaningful or words)[:32]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def init_local(conn):
|
|
40
|
+
if conn.dialect.name != "sqlite":
|
|
41
|
+
return
|
|
42
|
+
conn.execute(text("""CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
|
|
43
|
+
id UNINDEXED, resource_id UNINDEXED, title, body, tokenize='unicode61')"""))
|
|
44
|
+
for action, suffix, statement in (
|
|
45
|
+
("INSERT", "insert", "INSERT INTO knowledge_fts VALUES (new.id, new.resource_id, new.title, new.body);"),
|
|
46
|
+
("DELETE", "delete", "DELETE FROM knowledge_fts WHERE id=old.id;"),
|
|
47
|
+
):
|
|
48
|
+
conn.execute(text(f"CREATE TRIGGER IF NOT EXISTS knowledge_fts_{suffix} AFTER {action} "
|
|
49
|
+
f"ON knowledge_chunks BEGIN {statement} END"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RetrievalBackend(Protocol):
|
|
53
|
+
async def search(self, collection: str, query: str, limit: int,
|
|
54
|
+
*, current_ids: list[str] | None = None) -> IndexSearch: ...
|
|
55
|
+
async def replace(self, collection: str, chunks: list[dict]) -> None: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def local_search(db, collection, query, limit=100, *, path_prefix=None):
|
|
59
|
+
words = search_terms(query)
|
|
60
|
+
if not words:
|
|
61
|
+
return []
|
|
62
|
+
if db.bind.dialect.name == "sqlite":
|
|
63
|
+
result = await db.execute(text("""SELECT knowledge_fts.id FROM knowledge_fts
|
|
64
|
+
JOIN knowledge_chunks c ON c.id=knowledge_fts.id
|
|
65
|
+
JOIN knowledge_revisions r ON r.id=c.revision_id
|
|
66
|
+
JOIN knowledge_documents d ON d.current_revision=r.id AND d.id=r.document_id
|
|
67
|
+
WHERE knowledge_fts MATCH :query AND knowledge_fts.resource_id=:collection
|
|
68
|
+
AND d.resource_id=:collection AND r.status='published'
|
|
69
|
+
AND (CAST(:path_prefix AS TEXT) IS NULL OR substr(d.path, 1, length(:path_prefix))=:path_prefix)
|
|
70
|
+
AND (r.expires_at IS NULL OR julianday(r.expires_at)>julianday('now'))
|
|
71
|
+
ORDER BY bm25(knowledge_fts, 0, 0, 4, 1), knowledge_fts.id LIMIT :limit"""),
|
|
72
|
+
{"query": " OR ".join('"' + w + '"' for w in words), "collection": collection,
|
|
73
|
+
"limit": limit, "path_prefix": path_prefix})
|
|
74
|
+
else:
|
|
75
|
+
result = await db.execute(text("""SELECT c.id FROM knowledge_chunks c
|
|
76
|
+
JOIN knowledge_revisions r ON r.id=c.revision_id
|
|
77
|
+
JOIN knowledge_documents d ON d.current_revision=r.id AND d.id=r.document_id
|
|
78
|
+
WHERE c.resource_id=:collection AND d.resource_id=:collection AND r.status='published'
|
|
79
|
+
AND (CAST(:path_prefix AS TEXT) IS NULL OR substr(d.path, 1, length(:path_prefix))=:path_prefix)
|
|
80
|
+
AND (r.expires_at IS NULL OR r.expires_at>CURRENT_TIMESTAMP)
|
|
81
|
+
AND to_tsvector('simple', c.title || ' ' || c.body) @@ plainto_tsquery('simple', :query)
|
|
82
|
+
ORDER BY ts_rank(to_tsvector('simple', c.title || ' ' || c.body),
|
|
83
|
+
plainto_tsquery('simple', :query)) DESC, c.id LIMIT :limit"""),
|
|
84
|
+
{"query": " ".join(words), "collection": collection, "limit": limit, "path_prefix": path_prefix})
|
|
85
|
+
return list(result.scalars())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fusion_scores(*rankings):
|
|
89
|
+
scores = {}
|
|
90
|
+
for ranking in rankings:
|
|
91
|
+
for rank, key in enumerate(dict.fromkeys(ranking), 1):
|
|
92
|
+
scores[key] = scores.get(key, 0) + 1 / (60 + rank)
|
|
93
|
+
return scores
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fuse(*rankings):
|
|
97
|
+
scores = fusion_scores(*rankings)
|
|
98
|
+
return sorted(scores, key=lambda key: (-scores[key], key))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class IndexSearch:
|
|
103
|
+
"""Untrusted retrieval signals; only the registry can establish visibility/freshness."""
|
|
104
|
+
|
|
105
|
+
lexical: list[str] = field(default_factory=list)
|
|
106
|
+
semantic: list[str] = field(default_factory=list)
|
|
107
|
+
indexed_chunks: int = 0
|
|
108
|
+
model_matches: int = 0
|
|
109
|
+
compatible_chunks: int = 0
|
|
110
|
+
lexical_scores: dict[str, float] = field(default_factory=dict)
|
|
111
|
+
vector_distances: dict[str, float] = field(default_factory=dict)
|
|
112
|
+
timings_ms: dict[str, float] = field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def ids(self):
|
|
116
|
+
return fuse(self.lexical, self.semantic)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def elapsed_ms(start):
|
|
120
|
+
return round((perf_counter() - start) * 1000, 3)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class IndexUnavailable(Exception):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PgVectorBackend:
|
|
128
|
+
def __init__(self):
|
|
129
|
+
self.dsn = env("KNOWLEDGE_PG_URL", "")
|
|
130
|
+
self.url = env("KNOWLEDGE_EMBEDDING_URL", "").rstrip("/")
|
|
131
|
+
self.key = env("KNOWLEDGE_EMBEDDING_KEY", "")
|
|
132
|
+
self.model = env("KNOWLEDGE_EMBEDDING_MODEL", "")
|
|
133
|
+
# Changing a model/endpoint invalidates old vectors even when dimensions match.
|
|
134
|
+
self.fingerprint = hashlib.sha256((self.url + "\n" + self.model).encode()).hexdigest()
|
|
135
|
+
|
|
136
|
+
@asynccontextmanager
|
|
137
|
+
async def connection(self):
|
|
138
|
+
try:
|
|
139
|
+
import asyncpg
|
|
140
|
+
conn = await asyncpg.connect(self.dsn, timeout=10, command_timeout=30)
|
|
141
|
+
try:
|
|
142
|
+
yield conn
|
|
143
|
+
finally:
|
|
144
|
+
await conn.close()
|
|
145
|
+
except Exception: # noqa: BLE001 - never expose driver errors containing connection credentials
|
|
146
|
+
# Driver errors and HTTP bodies can contain credentials or document content.
|
|
147
|
+
raise IndexUnavailable("Knowledge index unavailable; check server configuration and connectivity.") from None
|
|
148
|
+
|
|
149
|
+
async def embeddings(self, texts):
|
|
150
|
+
if not self.url or not self.key or not self.model:
|
|
151
|
+
raise IndexUnavailable("Configure a LiteLLM embedding endpoint, model and scoped API key.")
|
|
152
|
+
try:
|
|
153
|
+
async with httpx.AsyncClient(timeout=45, follow_redirects=False, trust_env=False) as client:
|
|
154
|
+
response = await client.post(self.url + "/embeddings",
|
|
155
|
+
headers={"Authorization": "Bearer " + self.key},
|
|
156
|
+
json={"model": self.model, "input": texts, "encoding_format": "float"})
|
|
157
|
+
response.raise_for_status()
|
|
158
|
+
rows = sorted(response.json()["data"], key=lambda r: r["index"])
|
|
159
|
+
if [r["index"] for r in rows] != list(range(len(texts))):
|
|
160
|
+
raise ValueError("Missing embedding")
|
|
161
|
+
vectors = [r["embedding"] for r in rows]
|
|
162
|
+
dimension = len(vectors[0])
|
|
163
|
+
if not 1 <= dimension <= 16000:
|
|
164
|
+
raise ValueError("Invalid dimension")
|
|
165
|
+
for vector in vectors:
|
|
166
|
+
if len(vector) != dimension or any(type(x) not in (int, float) or not math.isfinite(x) for x in vector):
|
|
167
|
+
raise ValueError("Invalid vector")
|
|
168
|
+
if not any(vector):
|
|
169
|
+
raise ValueError("Zero vector")
|
|
170
|
+
return vectors
|
|
171
|
+
except (httpx.HTTPError, KeyError, TypeError, ValueError, IndexError):
|
|
172
|
+
raise IndexUnavailable("Embedding request failed; check the configured LiteLLM model and key.") from None
|
|
173
|
+
|
|
174
|
+
async def replace(self, collection, chunks):
|
|
175
|
+
vectors = []
|
|
176
|
+
for start in range(0, len(chunks), 32):
|
|
177
|
+
vectors.extend(await self.embeddings([c["title"] + "\n" + c["body"] for c in chunks[start:start + 32]]))
|
|
178
|
+
if vectors and any(len(v) != len(vectors[0]) for v in vectors):
|
|
179
|
+
raise IndexUnavailable("Embedding dimensions changed during indexing.")
|
|
180
|
+
async with self.connection() as conn, conn.transaction():
|
|
181
|
+
await conn.execute("SELECT pg_advisory_xact_lock(hashtext($1))", collection)
|
|
182
|
+
await conn.execute("DELETE FROM knowledge_search_chunks WHERE collection=$1", collection)
|
|
183
|
+
await conn.executemany("""INSERT INTO knowledge_search_chunks
|
|
184
|
+
(id, collection, model, title, body, embedding) VALUES ($1,$2,$3,$4,$5,$6::vector)""",
|
|
185
|
+
[(c["id"], collection, self.fingerprint, c["title"], c["body"], json.dumps(v))
|
|
186
|
+
for c, v in zip(chunks, vectors, strict=True)])
|
|
187
|
+
|
|
188
|
+
async def search(self, collection, query, limit=100, *, current_ids=None):
|
|
189
|
+
started = stage = perf_counter()
|
|
190
|
+
vector = (await self.embeddings([query]))[0]
|
|
191
|
+
timings = {"embedding": elapsed_ms(stage)}
|
|
192
|
+
stage = perf_counter()
|
|
193
|
+
async with self.connection() as conn:
|
|
194
|
+
timings["index_connect"] = elapsed_ms(stage)
|
|
195
|
+
# Coverage and rankings must describe the same index, even during an atomic rebuild.
|
|
196
|
+
async with conn.transaction(isolation="repeatable_read", readonly=True):
|
|
197
|
+
stage = perf_counter()
|
|
198
|
+
coverage = await conn.fetchrow("""SELECT count(*) AS indexed,
|
|
199
|
+
count(*) FILTER (WHERE model=$3) AS model_matches,
|
|
200
|
+
count(*) FILTER (WHERE model=$3 AND vector_dims(embedding)=$4) AS compatible
|
|
201
|
+
FROM knowledge_search_chunks
|
|
202
|
+
WHERE collection=$1 AND ($2::text[] IS NULL OR id=ANY($2::text[]))""",
|
|
203
|
+
collection, current_ids, self.fingerprint, len(vector))
|
|
204
|
+
timings["index_coverage"] = elapsed_ms(stage)
|
|
205
|
+
stage = perf_counter()
|
|
206
|
+
# Apply authoritative current scope before ranking/limit, including exact vector search.
|
|
207
|
+
semantic = await conn.fetch("""SELECT id, embedding <=> $4::vector AS distance
|
|
208
|
+
FROM knowledge_search_chunks
|
|
209
|
+
WHERE collection=$1 AND model=$2 AND vector_dims(embedding)=$3
|
|
210
|
+
AND ($6::text[] IS NULL OR id=ANY($6::text[]))
|
|
211
|
+
ORDER BY embedding <=> $4::vector, id LIMIT $5""",
|
|
212
|
+
collection, self.fingerprint, len(vector), json.dumps(vector), limit, current_ids)
|
|
213
|
+
timings["vector_search"] = elapsed_ms(stage)
|
|
214
|
+
stage = perf_counter()
|
|
215
|
+
lexical = await conn.fetch("""SELECT id, ts_rank(search, plainto_tsquery('simple', $2)) AS score
|
|
216
|
+
FROM knowledge_search_chunks
|
|
217
|
+
WHERE collection=$1 AND search @@ plainto_tsquery('simple', $2)
|
|
218
|
+
AND ($4::text[] IS NULL OR id=ANY($4::text[]))
|
|
219
|
+
ORDER BY ts_rank(search, plainto_tsquery('simple', $2)) DESC, id LIMIT $3""",
|
|
220
|
+
collection, query, limit, current_ids)
|
|
221
|
+
timings["external_text_search"] = elapsed_ms(stage)
|
|
222
|
+
timings["backend_total"] = elapsed_ms(started)
|
|
223
|
+
return IndexSearch(
|
|
224
|
+
lexical=[r["id"] for r in lexical], semantic=[r["id"] for r in semantic],
|
|
225
|
+
indexed_chunks=coverage["indexed"], model_matches=coverage["model_matches"],
|
|
226
|
+
compatible_chunks=coverage["compatible"],
|
|
227
|
+
lexical_scores={r["id"]: float(r["score"]) for r in lexical},
|
|
228
|
+
vector_distances={r["id"]: float(r["distance"]) for r in semantic}, timings_ms=timings,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def backend() -> RetrievalBackend | None:
|
|
233
|
+
return PgVectorBackend() if env("KNOWLEDGE_PG_URL") else None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Collection-bound MCP API. The broker supplies the collection and authenticated identity."""
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import mcp_types as types
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
6
|
+
|
|
7
|
+
from . import knowledge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Model(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Search(Model):
|
|
15
|
+
query: str = Field(min_length=1, max_length=1000)
|
|
16
|
+
limit: int = Field(default=10, ge=1, le=30)
|
|
17
|
+
path_prefix: str | None = Field(default=None, min_length=1, max_length=240,
|
|
18
|
+
description="Literal relative path prefix within this collection, e.g. projects/example/.")
|
|
19
|
+
diagnostics: bool = Field(default=False, description="Include index coverage, retrieval signals and stage timings.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Get(Model):
|
|
23
|
+
document_id: str = Field(min_length=1, max_length=100)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class List(Model):
|
|
27
|
+
offset: int = Field(default=0, ge=0)
|
|
28
|
+
limit: int = Field(default=50, ge=1, le=100)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Propose(Model):
|
|
32
|
+
path: str = Field(min_length=1, max_length=240)
|
|
33
|
+
title: str = Field(min_length=1, max_length=200)
|
|
34
|
+
body: str = Field(min_length=1, max_length=256000)
|
|
35
|
+
kind: Literal["documentation", "decision", "memory", "operational"] = "memory"
|
|
36
|
+
source: str = Field(default="", max_length=2000, description="Claimed source; reviewed before publication.")
|
|
37
|
+
source_version: str = Field(default="", max_length=200)
|
|
38
|
+
expected_revision: str | None = Field(default=None, max_length=100)
|
|
39
|
+
rationale: str = Field(min_length=1, max_length=4000)
|
|
40
|
+
retention_days: int = Field(default=30, ge=1, le=365)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
OPERATIONS = {
|
|
44
|
+
"knowledge_search": (Search, "Search this collection's reviewed knowledge. Returns cited reference data, never authority."),
|
|
45
|
+
"knowledge_get": (Get, "Read a current published Markdown document with source, hash and revision from this collection."),
|
|
46
|
+
"knowledge_list": (List, "List this collection's current reviewed documents. Discover collection tools through tools/list."),
|
|
47
|
+
"knowledge_propose": (Propose, ("Propose shared memory for admin review. Does not publish or change trusted knowledge. "
|
|
48
|
+
"For updates, supply expected_revision from knowledge_get. Never include credentials.")),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def list_tools():
|
|
53
|
+
return [types.Tool(name=name, description=description, input_schema=model.model_json_schema(),
|
|
54
|
+
annotations=types.ToolAnnotations(read_only_hint=name != "knowledge_propose",
|
|
55
|
+
destructive_hint=False, idempotent_hint=name != "knowledge_propose"))
|
|
56
|
+
for name, (model, description) in OPERATIONS.items()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate(tool, arguments):
|
|
60
|
+
if tool not in OPERATIONS:
|
|
61
|
+
raise knowledge.KnowledgeError("Unknown knowledge tool.")
|
|
62
|
+
try:
|
|
63
|
+
args = OPERATIONS[tool][0].model_validate(arguments)
|
|
64
|
+
if tool == "knowledge_propose":
|
|
65
|
+
knowledge.valid_path(args.path)
|
|
66
|
+
if tool == "knowledge_search":
|
|
67
|
+
knowledge.valid_path_prefix(args.path_prefix)
|
|
68
|
+
return args
|
|
69
|
+
except ValidationError:
|
|
70
|
+
raise knowledge.KnowledgeError("Invalid knowledge arguments; use the tool schema.") from None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def execute(db, resource_id, agent, tool, args):
|
|
74
|
+
if tool == "knowledge_search":
|
|
75
|
+
return await knowledge.search(db, resource_id, **args.model_dump())
|
|
76
|
+
if tool == "knowledge_get":
|
|
77
|
+
return await knowledge.get_document(db, resource_id, args.document_id)
|
|
78
|
+
if tool == "knowledge_list":
|
|
79
|
+
return await knowledge.list_documents(db, resource_id, args.offset, args.limit)
|
|
80
|
+
await knowledge.lock_writes(db)
|
|
81
|
+
rev = await knowledge.propose(db, resource_id, agent=agent, **args.model_dump())
|
|
82
|
+
return {"proposal_id": rev.id, "status": "pending", "message": "Awaiting administrator review; not searchable."}
|
keywright_server/llm.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""LLM gateways: LiteLLM as the data plane, Keywright as the control plane.
|
|
2
|
+
|
|
3
|
+
Resource config for kind=llm:
|
|
4
|
+
{"url": "http://litellm:4000", "master_key": "sk-...",
|
|
5
|
+
"models": ["claude-sonnet-5", "gpt-5.6"], # what agents may ask for; refreshed by probe
|
|
6
|
+
"limits": {"max_budget": 2.0, "rpm_limit": 30, "tpm_limit": null, "max_parallel_requests": 4},
|
|
7
|
+
"agent_budget": {"max_budget": 10.0, "budget_duration": "1d"},
|
|
8
|
+
"health": {"db": "connected", "version": "...", "checked_at": "..."}}
|
|
9
|
+
|
|
10
|
+
Only Keywright holds the master key and the real provider keys stay in LiteLLM's environment. An
|
|
11
|
+
approval on `llm/<gateway>/model/<model>` becomes a LiteLLM virtual key with exactly that model
|
|
12
|
+
list, the grant's lifetime as its `duration`, and the gateway's per-grant limits. The agent is a
|
|
13
|
+
LiteLLM internal user carrying the daily or monthly budget, so a key's ceiling and the agent's
|
|
14
|
+
ceiling are both enforced upstream (never a team: a key in a team ignores its own budget).
|
|
15
|
+
|
|
16
|
+
Budgets are enforced against spend LiteLLM reads from its database; without one the proxy serves
|
|
17
|
+
past every limit. A key is therefore never minted unless the gateway reports its database
|
|
18
|
+
connected, and the failure is the human's to see, not the agent's to slip through.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from keywright.patterns import model_segment
|
|
29
|
+
from sqlalchemy import select
|
|
30
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
31
|
+
|
|
32
|
+
from . import audit
|
|
33
|
+
from .models import Agent, Grant, LlmKey, Resource, now
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
TIMEOUT = 15.0
|
|
38
|
+
LIMIT_KEYS = ("max_budget", "rpm_limit", "tpm_limit", "max_parallel_requests")
|
|
39
|
+
DEFAULT_LIMITS: dict[str, Any] = {"max_budget": 2.0, "rpm_limit": 30, "tpm_limit": None, "max_parallel_requests": 4}
|
|
40
|
+
DEFAULT_AGENT_BUDGET: dict[str, Any] = {"max_budget": 10.0, "budget_duration": "1d"}
|
|
41
|
+
BUDGET_DURATIONS = ("1h", "1d", "7d", "30d")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GatewayError(RuntimeError):
|
|
45
|
+
"""The gateway refused, is unreachable, or cannot enforce budgets. Never silently allowed."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _client(config: dict[str, Any], timeout: float = TIMEOUT) -> httpx.AsyncClient:
|
|
49
|
+
return httpx.AsyncClient(base_url=str(config.get("url") or "").rstrip("/"), timeout=timeout,
|
|
50
|
+
headers={"Authorization": f"Bearer {config.get('master_key') or ''}"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def limits_of(config: dict[str, Any]) -> dict[str, Any]:
|
|
54
|
+
return {k: (config.get("limits") or {}).get(k, DEFAULT_LIMITS[k]) for k in LIMIT_KEYS}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def agent_budget_of(config: dict[str, Any]) -> dict[str, Any]:
|
|
58
|
+
return {**DEFAULT_AGENT_BUDGET, **{k: v for k, v in (config.get("agent_budget") or {}).items() if v is not None}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def model_paths(res: Resource) -> dict[str, str]:
|
|
62
|
+
"""model name -> permission path, for every model the gateway offers."""
|
|
63
|
+
return {m: f"llm/{res.name}/model/{model_segment(m)}" for m in (res.config.get("models") or [])}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def models_for(res: Resource, patterns: list[str]) -> list[str]:
|
|
67
|
+
"""The gateway's models a set of approved patterns reaches. Patterns are matched by the model
|
|
68
|
+
segment, so `llm/gw/model/*` or `llm/gw` reach every model and a literal reaches one."""
|
|
69
|
+
from keywright.patterns import matches
|
|
70
|
+
|
|
71
|
+
return [m for m, path in model_paths(res).items() if any(matches(p, path) for p in patterns)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def user_id(agent: Agent) -> str:
|
|
75
|
+
return f"kw-{agent.id}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---- gateway calls ------------------------------------------------------------------------------
|
|
79
|
+
async def readiness(config: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
"""{"db": "connected"|..., "version": ...}; raises GatewayError when unreachable."""
|
|
81
|
+
try:
|
|
82
|
+
async with _client(config) as c:
|
|
83
|
+
r = await c.get("/health/readiness")
|
|
84
|
+
data = r.json() if r.content else {}
|
|
85
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
86
|
+
raise GatewayError(f"gateway unreachable: {type(e).__name__}: {e}") from e
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
raise GatewayError("gateway readiness returned no JSON object")
|
|
89
|
+
return {"db": str(data.get("db") or "unknown"), "version": str(data.get("litellm_version") or ""),
|
|
90
|
+
"status": str(data.get("status") or r.status_code)}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def enforces_budgets(health: dict[str, Any] | None) -> bool:
|
|
94
|
+
return bool(health) and str(health.get("db", "")).lower() == "connected"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _request(config: dict[str, Any], method: str, path: str, what: str, **kw: Any) -> httpx.Response:
|
|
98
|
+
"""One authenticated call to the gateway; transport failures and 4xx/5xx are GatewayErrors."""
|
|
99
|
+
try:
|
|
100
|
+
async with _client(config) as c:
|
|
101
|
+
r = await c.request(method, path, **kw)
|
|
102
|
+
except httpx.HTTPError as e:
|
|
103
|
+
raise GatewayError(f"could not {what}: gateway unreachable: {type(e).__name__}: {e}") from e
|
|
104
|
+
_raise_for(r, what)
|
|
105
|
+
return r
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def list_models(config: dict[str, Any]) -> list[str]:
|
|
109
|
+
r = await _request(config, "GET", "/v1/models", "list models")
|
|
110
|
+
try:
|
|
111
|
+
data = r.json().get("data") or []
|
|
112
|
+
except ValueError as e:
|
|
113
|
+
raise GatewayError("could not list models: no JSON") from e
|
|
114
|
+
return sorted({str(m.get("id")) for m in data if isinstance(m, dict) and m.get("id")})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def probe(config: dict[str, Any]) -> tuple[bool, str, dict[str, Any]]:
|
|
118
|
+
"""(ok, message, health). ok means reachable, authenticated and able to enforce budgets."""
|
|
119
|
+
try:
|
|
120
|
+
health = await readiness(config)
|
|
121
|
+
except GatewayError as e:
|
|
122
|
+
return False, str(e), {}
|
|
123
|
+
health["checked_at"] = now().isoformat()
|
|
124
|
+
try:
|
|
125
|
+
models = await list_models(config)
|
|
126
|
+
except (httpx.HTTPError, GatewayError, ValueError) as e:
|
|
127
|
+
return False, f"reachable but the master key was refused or /v1/models failed: {e}", health
|
|
128
|
+
health["models"] = models
|
|
129
|
+
if not enforces_budgets(health):
|
|
130
|
+
return False, (f"LiteLLM {health.get('version') or ''} reports its database {health['db']!r}. Budgets are "
|
|
131
|
+
"not enforced without a database; no key will be minted until it is connected."), health
|
|
132
|
+
return True, f"LiteLLM {health.get('version') or ''}: database connected, {len(models)} models.", health
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _raise_for(r: httpx.Response, what: str) -> None:
|
|
136
|
+
if r.status_code >= 400:
|
|
137
|
+
try:
|
|
138
|
+
body = r.json()
|
|
139
|
+
msg = body.get("error", body) if isinstance(body, dict) else body
|
|
140
|
+
if isinstance(msg, dict):
|
|
141
|
+
msg = msg.get("message", msg)
|
|
142
|
+
except ValueError:
|
|
143
|
+
msg = r.text[:300]
|
|
144
|
+
raise GatewayError(f"could not {what}: HTTP {r.status_code}: {msg}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def ensure_user(config: dict[str, Any], agent: Agent) -> None:
|
|
148
|
+
"""The agent as a LiteLLM internal user carrying the per-agent budget window. Idempotent: the
|
|
149
|
+
budget is re-applied on every mint so a change on the resource page reaches upstream."""
|
|
150
|
+
budget = agent_budget_of(config)
|
|
151
|
+
body = {"user_id": user_id(agent), "user_alias": f"keywright:{agent.name}",
|
|
152
|
+
"max_budget": budget["max_budget"], "budget_duration": budget["budget_duration"]}
|
|
153
|
+
try:
|
|
154
|
+
async with _client(config) as c:
|
|
155
|
+
r = await c.get("/user/info", params={"user_id": user_id(agent)})
|
|
156
|
+
except httpx.HTTPError as e:
|
|
157
|
+
raise GatewayError(f"could not look the agent up: gateway unreachable: {type(e).__name__}: {e}") from e
|
|
158
|
+
# A missing user is a 404 on current LiteLLM and a 200 with a null user_info on older ones.
|
|
159
|
+
try:
|
|
160
|
+
info = r.json() if r.status_code == 200 else {}
|
|
161
|
+
except ValueError:
|
|
162
|
+
info = {}
|
|
163
|
+
exists = isinstance(info, dict) and info.get("user_info") is not None
|
|
164
|
+
if exists:
|
|
165
|
+
await _request(config, "POST", "/user/update", "update the agent's budget", json=body)
|
|
166
|
+
else:
|
|
167
|
+
await _request(config, "POST", "/user/new", "create the agent's budget",
|
|
168
|
+
json={**body, "user_role": "internal_user", "auto_create_key": False})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def generate_key(config: dict[str, Any], agent: Agent, models: list[str], expires_at: datetime,
|
|
172
|
+
limits: dict[str, Any], alias: str, metadata: dict[str, Any]) -> str:
|
|
173
|
+
seconds = max(60, int((expires_at - now()).total_seconds()))
|
|
174
|
+
body: dict[str, Any] = {"key_alias": alias, "user_id": user_id(agent), "models": list(models),
|
|
175
|
+
"duration": f"{seconds}s", "metadata": {"keywright": metadata}}
|
|
176
|
+
body.update({k: v for k, v in limits.items() if k in LIMIT_KEYS and v is not None})
|
|
177
|
+
r = await _request(config, "POST", "/key/generate", "generate a key", json=body)
|
|
178
|
+
try:
|
|
179
|
+
key = r.json().get("key")
|
|
180
|
+
except ValueError:
|
|
181
|
+
key = None
|
|
182
|
+
if not isinstance(key, str) or not key:
|
|
183
|
+
raise GatewayError("gateway returned no key")
|
|
184
|
+
return key
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def delete_key(config: dict[str, Any], key: str) -> None:
|
|
188
|
+
try:
|
|
189
|
+
await _request(config, "POST", "/key/delete", "delete a key", json={"keys": [key]})
|
|
190
|
+
except GatewayError as e:
|
|
191
|
+
if "HTTP 404" not in str(e): # already gone upstream is the outcome wanted
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def update_key_models(config: dict[str, Any], key: str, models: list[str]) -> None:
|
|
196
|
+
await _request(config, "POST", "/key/update", "narrow a key", json={"key": key, "models": list(models)})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def key_spend(config: dict[str, Any], key: str) -> dict[str, Any] | None:
|
|
200
|
+
"""{"spend": float, "max_budget": float|None} from /key/info, or None when unavailable."""
|
|
201
|
+
try:
|
|
202
|
+
async with _client(config, timeout=5.0) as c:
|
|
203
|
+
r = await c.get("/key/info", params={"key": key})
|
|
204
|
+
if r.status_code != 200:
|
|
205
|
+
return None
|
|
206
|
+
info = r.json().get("info") or {}
|
|
207
|
+
return {"spend": float(info.get("spend") or 0.0), "max_budget": info.get("max_budget")}
|
|
208
|
+
except (httpx.HTTPError, ValueError, TypeError):
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---- lifecycle around grants --------------------------------------------------------------------
|
|
213
|
+
def llm_grants(grants: list[Grant]) -> list[Grant]:
|
|
214
|
+
return [g for g in grants if g.pattern.split("/", 1)[0] == "llm"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def mint_for_grants(db: AsyncSession, agent: Agent, grants: list[Grant], *, request_id: str | None,
|
|
218
|
+
reason: str = "") -> list[LlmKey]:
|
|
219
|
+
"""One virtual key per gateway for the LLM grants of one approval. Added to `db` but not
|
|
220
|
+
committed: the caller commits the approval and the keys together, or rolls both back. Raises
|
|
221
|
+
GatewayError when any gateway cannot mint; keys already minted upstream are deleted again."""
|
|
222
|
+
by_gateway: dict[str, list[Grant]] = {}
|
|
223
|
+
for g in llm_grants(grants):
|
|
224
|
+
by_gateway.setdefault(g.pattern.split("/")[1], []).append(g)
|
|
225
|
+
if not by_gateway:
|
|
226
|
+
return []
|
|
227
|
+
resources = {r.name: r for r in (await db.execute(
|
|
228
|
+
select(Resource).where(Resource.kind == "llm", Resource.name.in_(by_gateway)))).scalars().all()}
|
|
229
|
+
minted: list[tuple[LlmKey, Resource]] = []
|
|
230
|
+
try:
|
|
231
|
+
for name, members in by_gateway.items():
|
|
232
|
+
res = resources.get(name)
|
|
233
|
+
if res is None:
|
|
234
|
+
raise GatewayError(f"no LLM gateway named {name}")
|
|
235
|
+
health = await readiness(res.config)
|
|
236
|
+
if not enforces_budgets(health):
|
|
237
|
+
raise GatewayError(f"{name}: LiteLLM reports its database {health['db']!r}; budgets would not be "
|
|
238
|
+
"enforced, so no key is minted. Connect the database and decide again.")
|
|
239
|
+
models = models_for(res, [g.pattern for g in members])
|
|
240
|
+
if not models:
|
|
241
|
+
raise GatewayError(f"{name}: none of the gateway's models match; refresh the model list on the "
|
|
242
|
+
"resource page (Test connection)")
|
|
243
|
+
expires = min((g.expires_at for g in members if g.expires_at), default=None)
|
|
244
|
+
if expires is None:
|
|
245
|
+
raise GatewayError(f"{name}: an LLM grant needs a duration; choose timed or session access")
|
|
246
|
+
limits = limits_of(res.config)
|
|
247
|
+
await ensure_user(res.config, agent)
|
|
248
|
+
alias = f"kw:{agent.name}:{request_id or members[0].id}:{name}"[:100]
|
|
249
|
+
key = await generate_key(res.config, agent, models, expires, limits, alias,
|
|
250
|
+
{"agent": agent.name, "request_id": request_id, "reason": reason[:200],
|
|
251
|
+
"grant_ids": [g.id for g in members]})
|
|
252
|
+
row = LlmKey(resource_id=res.id, agent_id=agent.id, request_id=request_id, grant_ids=[g.id for g in members],
|
|
253
|
+
key=key, key_alias=alias, models=models, limits=limits, expires_at=expires)
|
|
254
|
+
db.add(row)
|
|
255
|
+
minted.append((row, res))
|
|
256
|
+
except GatewayError:
|
|
257
|
+
for row, res in minted:
|
|
258
|
+
try:
|
|
259
|
+
await delete_key(res.config, row.key)
|
|
260
|
+
except GatewayError as e:
|
|
261
|
+
log.warning("could not clean up key %s after a failed mint: %s", row.key_alias, e)
|
|
262
|
+
db.expunge(row)
|
|
263
|
+
raise
|
|
264
|
+
return [row for row, _ in minted]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def revoke_for_grants(db: AsyncSession, grant_ids: list[str], *, via: str = "ui") -> int:
|
|
268
|
+
"""Grants are gone; take the keys with them. A key whose grants are only partly revoked is
|
|
269
|
+
narrowed to the models still granted. Upstream failures are logged and audited, never hidden:
|
|
270
|
+
the row is marked revoked regardless so the broker stops using it."""
|
|
271
|
+
if not grant_ids:
|
|
272
|
+
return 0
|
|
273
|
+
wanted = set(grant_ids)
|
|
274
|
+
rows = [k for k in (await db.execute(select(LlmKey).where(LlmKey.revoked_at.is_(None)))).scalars().all()
|
|
275
|
+
if wanted & set(k.grant_ids or [])]
|
|
276
|
+
n = 0
|
|
277
|
+
for row in rows:
|
|
278
|
+
res = await db.get(Resource, row.resource_id)
|
|
279
|
+
remaining_ids = [g for g in (row.grant_ids or []) if g not in wanted]
|
|
280
|
+
remaining = (await db.execute(select(Grant).where(Grant.id.in_(remaining_ids), Grant.revoked_at.is_(None)))
|
|
281
|
+
).scalars().all() if remaining_ids else []
|
|
282
|
+
outcome = "revoked"
|
|
283
|
+
try:
|
|
284
|
+
if remaining and res is not None:
|
|
285
|
+
keep = models_for(res, [g.pattern for g in remaining])
|
|
286
|
+
if keep:
|
|
287
|
+
await update_key_models(res.config, row.key, keep)
|
|
288
|
+
row.models, row.grant_ids = keep, [g.id for g in remaining]
|
|
289
|
+
outcome = "narrowed"
|
|
290
|
+
else:
|
|
291
|
+
await delete_key(res.config, row.key)
|
|
292
|
+
elif res is not None:
|
|
293
|
+
await delete_key(res.config, row.key)
|
|
294
|
+
except GatewayError as e:
|
|
295
|
+
log.error("LiteLLM key %s could not be %s upstream: %s", row.key_alias, outcome, e)
|
|
296
|
+
outcome = f"{outcome}_locally_only"
|
|
297
|
+
if outcome != "narrowed":
|
|
298
|
+
row.revoked_at = now()
|
|
299
|
+
n += 1
|
|
300
|
+
await audit.record(db, tool="llm.key", decision=outcome, patterns=[f"llm/{res.name}" if res else "llm"],
|
|
301
|
+
detail={"key_alias": row.key_alias, "agent_id": row.agent_id, "models": row.models,
|
|
302
|
+
"via": via, "error": None if outcome in ("revoked", "narrowed") else "upstream"},
|
|
303
|
+
commit=False)
|
|
304
|
+
return n
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def revoke_for_agent(db: AsyncSession, agent_id: str, *, via: str) -> int:
|
|
308
|
+
rows = (await db.execute(select(LlmKey).where(LlmKey.agent_id == agent_id, LlmKey.revoked_at.is_(None)))
|
|
309
|
+
).scalars().all()
|
|
310
|
+
ids = [g for k in rows for g in (k.grant_ids or [])]
|
|
311
|
+
return await revoke_for_grants(db, ids, via=via) if ids else 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def revoke_for_resource(db: AsyncSession, res: Resource, *, via: str) -> int:
|
|
315
|
+
rows = (await db.execute(select(LlmKey).where(LlmKey.resource_id == res.id, LlmKey.revoked_at.is_(None)))
|
|
316
|
+
).scalars().all()
|
|
317
|
+
ids = [g for k in rows for g in (k.grant_ids or [])]
|
|
318
|
+
return await revoke_for_grants(db, ids, via=via) if ids else 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def active_keys(db: AsyncSession, res: Resource) -> list[LlmKey]:
|
|
322
|
+
at = now()
|
|
323
|
+
rows = (await db.execute(select(LlmKey).where(LlmKey.resource_id == res.id, LlmKey.revoked_at.is_(None))
|
|
324
|
+
.order_by(LlmKey.created_at.desc()))).scalars().all()
|
|
325
|
+
return [k for k in rows if k.is_active(at)]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def exposure(keys: list[LlmKey], spend: dict[str, dict[str, Any] | None]) -> float:
|
|
329
|
+
"""If every active key were spent to its ceiling right now, this much more could go: the
|
|
330
|
+
number that matters more than what was spent."""
|
|
331
|
+
total = 0.0
|
|
332
|
+
for k in keys:
|
|
333
|
+
cap = (k.limits or {}).get("max_budget")
|
|
334
|
+
if cap is None:
|
|
335
|
+
continue
|
|
336
|
+
s = (spend.get(k.id) or {}).get("spend") or 0.0
|
|
337
|
+
total += max(0.0, float(cap) - float(s))
|
|
338
|
+
return total
|