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.
Files changed (127) hide show
  1. keywright_server/__init__.py +3 -0
  2. keywright_server/__main__.py +71 -0
  3. keywright_server/access.py +131 -0
  4. keywright_server/access_view.py +144 -0
  5. keywright_server/actions.py +30 -0
  6. keywright_server/approvals.py +433 -0
  7. keywright_server/audit.py +58 -0
  8. keywright_server/bundles.py +87 -0
  9. keywright_server/config.py +102 -0
  10. keywright_server/db.py +119 -0
  11. keywright_server/discovery.py +277 -0
  12. keywright_server/events.py +78 -0
  13. keywright_server/friction.py +242 -0
  14. keywright_server/integrations.py +64 -0
  15. keywright_server/inventory.py +415 -0
  16. keywright_server/inventory_mcp.py +288 -0
  17. keywright_server/jev.py +80 -0
  18. keywright_server/knowledge.py +403 -0
  19. keywright_server/knowledge_chunking.py +118 -0
  20. keywright_server/knowledge_index.py +233 -0
  21. keywright_server/knowledge_mcp.py +82 -0
  22. keywright_server/llm.py +338 -0
  23. keywright_server/llm_proxy.py +271 -0
  24. keywright_server/llm_usage.py +99 -0
  25. keywright_server/mail.py +312 -0
  26. keywright_server/mcp_oauth.py +422 -0
  27. keywright_server/mcp_proxy.py +128 -0
  28. keywright_server/mcp_server.py +1025 -0
  29. keywright_server/models.py +442 -0
  30. keywright_server/network_map.py +128 -0
  31. keywright_server/notify/__init__.py +8 -0
  32. keywright_server/notify/base.py +156 -0
  33. keywright_server/notify/homeassistant.py +221 -0
  34. keywright_server/notify/log.py +30 -0
  35. keywright_server/notify/manager.py +131 -0
  36. keywright_server/notify/webhook.py +79 -0
  37. keywright_server/nudges.py +121 -0
  38. keywright_server/omada.py +179 -0
  39. keywright_server/policy.py +265 -0
  40. keywright_server/policy_preview.py +151 -0
  41. keywright_server/reconciliation.py +188 -0
  42. keywright_server/result_inspection.py +293 -0
  43. keywright_server/semantic.py +451 -0
  44. keywright_server/semantic_questions.json +120 -0
  45. keywright_server/settings_store.py +44 -0
  46. keywright_server/ssh.py +290 -0
  47. keywright_server/tool_access.py +260 -0
  48. keywright_server/usage.py +121 -0
  49. keywright_server/web/__init__.py +0 -0
  50. keywright_server/web/admin.py +1126 -0
  51. keywright_server/web/app.py +154 -0
  52. keywright_server/web/auth.py +144 -0
  53. keywright_server/web/discovery_ui.py +112 -0
  54. keywright_server/web/friction_ui.py +92 -0
  55. keywright_server/web/inventory_ui.py +192 -0
  56. keywright_server/web/knowledge_ui.py +177 -0
  57. keywright_server/web/mail_ui.py +190 -0
  58. keywright_server/web/mcp_oauth_ui.py +87 -0
  59. keywright_server/web/network_map_ui.py +37 -0
  60. keywright_server/web/oidc.py +231 -0
  61. keywright_server/web/security.py +77 -0
  62. keywright_server/web/templates/_activity_row.html +25 -0
  63. keywright_server/web/templates/_agent_setup.html +50 -0
  64. keywright_server/web/templates/_collections_style.html +88 -0
  65. keywright_server/web/templates/_constraints.html +6 -0
  66. keywright_server/web/templates/_decide.html +39 -0
  67. keywright_server/web/templates/_device_kind_picker.html +66 -0
  68. keywright_server/web/templates/_hazards.html +6 -0
  69. keywright_server/web/templates/_icons.html +19 -0
  70. keywright_server/web/templates/_infrastructure_nav.html +8 -0
  71. keywright_server/web/templates/_integration_style.html +30 -0
  72. keywright_server/web/templates/_inventory_forms.html +36 -0
  73. keywright_server/web/templates/_inventory_style.html +36 -0
  74. keywright_server/web/templates/_live.html +10 -0
  75. keywright_server/web/templates/_mail_nav.html +5 -0
  76. keywright_server/web/templates/_mail_style.html +10 -0
  77. keywright_server/web/templates/_network_map_script.html +5 -0
  78. keywright_server/web/templates/_nudge_card.html +12 -0
  79. keywright_server/web/templates/_patterns.html +6 -0
  80. keywright_server/web/templates/_policy_preview_results.html +13 -0
  81. keywright_server/web/templates/_request_card.html +46 -0
  82. keywright_server/web/templates/_semantic.html +10 -0
  83. keywright_server/web/templates/_topology_controller.js +217 -0
  84. keywright_server/web/templates/_topology_layout.js +174 -0
  85. keywright_server/web/templates/_topology_render.js +117 -0
  86. keywright_server/web/templates/access.html +119 -0
  87. keywright_server/web/templates/agents.html +49 -0
  88. keywright_server/web/templates/audit.html +6 -0
  89. keywright_server/web/templates/base.html +198 -0
  90. keywright_server/web/templates/dashboard.html +226 -0
  91. keywright_server/web/templates/decision.html +26 -0
  92. keywright_server/web/templates/friction.html +36 -0
  93. keywright_server/web/templates/friction_review.html +21 -0
  94. keywright_server/web/templates/grants.html +61 -0
  95. keywright_server/web/templates/integration_picker.html +47 -0
  96. keywright_server/web/templates/inventory.html +21 -0
  97. keywright_server/web/templates/inventory_asset.html +42 -0
  98. keywright_server/web/templates/inventory_discovery.html +32 -0
  99. keywright_server/web/templates/inventory_discovery_review.html +34 -0
  100. keywright_server/web/templates/inventory_map.html +45 -0
  101. keywright_server/web/templates/inventory_networks.html +12 -0
  102. keywright_server/web/templates/inventory_subnet.html +41 -0
  103. keywright_server/web/templates/inventory_topology.html +22 -0
  104. keywright_server/web/templates/knowledge.html +25 -0
  105. keywright_server/web/templates/knowledge_document.html +7 -0
  106. keywright_server/web/templates/knowledge_proposal.html +7 -0
  107. keywright_server/web/templates/login.html +9 -0
  108. keywright_server/web/templates/mail.html +45 -0
  109. keywright_server/web/templates/mail_rules.html +24 -0
  110. keywright_server/web/templates/mail_settings.html +19 -0
  111. keywright_server/web/templates/mcp_oauth_continue.html +14 -0
  112. keywright_server/web/templates/policies.html +30 -0
  113. keywright_server/web/templates/request_detail.html +27 -0
  114. keywright_server/web/templates/requests.html +9 -0
  115. keywright_server/web/templates/resource_form.html +198 -0
  116. keywright_server/web/templates/resources.html +76 -0
  117. keywright_server/web/templates/settings.html +53 -0
  118. keywright_server/web/templates/setup.html +8 -0
  119. keywright_server/web/templates/usage.html +84 -0
  120. keywright_server/web/usage_ui.py +41 -0
  121. keywright_server-0.0.1.dist-info/METADATA +152 -0
  122. keywright_server-0.0.1.dist-info/RECORD +127 -0
  123. keywright_server-0.0.1.dist-info/WHEEL +4 -0
  124. keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
  125. keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
  126. keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
  127. 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."}
@@ -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