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,271 @@
|
|
|
1
|
+
"""The LLM proxy: an agent talks to `/llm/<gateway>/v1/...` with its Keywright token, and the call
|
|
2
|
+
reaches LiteLLM carrying the virtual key of the lease that allows it.
|
|
3
|
+
|
|
4
|
+
ANTHROPIC_BASE_URL=https://keywright.example/llm/anthropic ANTHROPIC_AUTH_TOKEN=kw_...
|
|
5
|
+
OPENAI_BASE_URL=https://keywright.example/llm/openai/v1 OPENAI_API_KEY=kw_...
|
|
6
|
+
|
|
7
|
+
Every call names a model, and the model is the permission path: `llm/<gateway>/model/<model>`.
|
|
8
|
+
The decision is the same as for a shell command (deny rule, active lease, allow rule, resource
|
|
9
|
+
default), and a call nobody has approved yet asks the human and waits, exactly like `ssh_run`.
|
|
10
|
+
The body and the response pass through untouched, streamed; only the credential changes hands.
|
|
11
|
+
A lease's key is the one minted at approval. Standing access (an allow rule or an open default)
|
|
12
|
+
gets a key too, minted on first use with the gateway's per-lease ceiling and renewed hourly, so
|
|
13
|
+
"always allow" still never means "unlimited"."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from datetime import timedelta
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from fastapi import APIRouter, Request
|
|
25
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
26
|
+
from keywright.patterns import model_segment
|
|
27
|
+
from sqlalchemy import select
|
|
28
|
+
|
|
29
|
+
from . import approvals, audit, llm, policy
|
|
30
|
+
from .config import settings
|
|
31
|
+
from .db import session
|
|
32
|
+
from .llm_usage import UsageObserver, numeric_usage
|
|
33
|
+
from .mcp_server import _request_or_reuse, resolve_agent
|
|
34
|
+
from .models import Agent, LlmKey, Resource, now
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
router = APIRouter()
|
|
38
|
+
|
|
39
|
+
STANDING_TTL = timedelta(hours=1)
|
|
40
|
+
# Never forwarded upstream: identity and framing are ours to set.
|
|
41
|
+
DROP_REQUEST_HEADERS = {"host", "authorization", "x-api-key", "content-length", "connection",
|
|
42
|
+
"x-keywright-session", "x-keywright-reason", "x-keywright-wait"}
|
|
43
|
+
DROP_RESPONSE_HEADERS = {"content-length", "transfer-encoding", "connection", "keep-alive"}
|
|
44
|
+
METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _error(status: int, kind: str, message: str, **extra: Any) -> JSONResponse:
|
|
48
|
+
"""One shape both the OpenAI and Anthropic SDKs surface verbatim: error.message, error.type."""
|
|
49
|
+
return JSONResponse({"error": {"message": message, "type": f"keywright_{kind}", "code": kind, **extra}},
|
|
50
|
+
status_code=status)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _model_of(body: bytes) -> tuple[str | None, dict | None]:
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(body) if body else None
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None, None
|
|
58
|
+
if isinstance(data, dict) and isinstance(data.get("model"), str):
|
|
59
|
+
return data["model"], data
|
|
60
|
+
return None, data if isinstance(data, dict) else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.api_route("/llm/{gateway}/{path:path}", methods=METHODS)
|
|
64
|
+
async def proxy(request: Request, gateway: str, path: str):
|
|
65
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
66
|
+
if "authorization" not in headers and headers.get("x-api-key"):
|
|
67
|
+
headers["authorization"] = "Bearer " + headers["x-api-key"] # the Anthropic SDK's spelling
|
|
68
|
+
sid = next((headers[h][:128] for h in ("x-keywright-session",) if headers.get(h)), None)
|
|
69
|
+
body = await request.body()
|
|
70
|
+
model, _ = _model_of(body) if request.method in ("POST", "PUT", "PATCH") else (None, None)
|
|
71
|
+
started = time.monotonic()
|
|
72
|
+
async with session() as db:
|
|
73
|
+
agent = await resolve_agent(db, headers)
|
|
74
|
+
if agent is None:
|
|
75
|
+
return _error(401, "unauthorized", "Missing or unknown Keywright agent token.")
|
|
76
|
+
res = (await db.execute(select(Resource).where(Resource.kind == "llm", Resource.name == gateway))
|
|
77
|
+
).scalar_one_or_none()
|
|
78
|
+
if res is None:
|
|
79
|
+
return _error(404, "unknown_gateway", f"No LLM gateway named {gateway!r}.")
|
|
80
|
+
if model is None and request.method != "GET":
|
|
81
|
+
return _error(400, "no_model", "The request body must name a model.")
|
|
82
|
+
if model is not None and model not in (res.config.get("models") or []):
|
|
83
|
+
return _error(400, "unknown_model", f"{model!r} is not offered by this gateway.",
|
|
84
|
+
models=list(res.config.get("models") or []))
|
|
85
|
+
key, denial = await _key_for(db, agent, res, sid, model, headers)
|
|
86
|
+
if denial is not None:
|
|
87
|
+
return denial
|
|
88
|
+
assert key is not None
|
|
89
|
+
upstream = f"{str(res.config.get('url') or '').rstrip('/')}/{path}"
|
|
90
|
+
pattern = f"llm/{res.name}/model/{model_segment(model)}" if model else f"llm/{res.name}"
|
|
91
|
+
detail = {"gateway": res.name, "model": model, "path": path, "method": request.method,
|
|
92
|
+
"resource_id": res.id, "gateway_key_id": key.id,
|
|
93
|
+
"key_alias": key.key_alias, "standing": not key.grant_ids}
|
|
94
|
+
agent_id, agent_name = agent.id, agent.name
|
|
95
|
+
out_headers = {k: v for k, v in headers.items() if k not in DROP_REQUEST_HEADERS}
|
|
96
|
+
out_headers["authorization"] = f"Bearer {key.key}"
|
|
97
|
+
client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=10.0))
|
|
98
|
+
try:
|
|
99
|
+
req = client.build_request(request.method, upstream, headers=out_headers, content=body,
|
|
100
|
+
params=request.query_params)
|
|
101
|
+
resp = await client.send(req, stream=True)
|
|
102
|
+
except httpx.HTTPError as e:
|
|
103
|
+
await client.aclose()
|
|
104
|
+
async with session() as db:
|
|
105
|
+
await audit.record(db, tool="llm.call", decision="error", agent=Agent(id=agent_id, name=agent_name),
|
|
106
|
+
session_id=sid, patterns=[pattern],
|
|
107
|
+
detail={**detail, "error": f"{type(e).__name__}: {e}"},
|
|
108
|
+
duration_ms=int((time.monotonic() - started) * 1000))
|
|
109
|
+
return _error(502, "gateway_unreachable", f"The LLM gateway did not answer: {type(e).__name__}.")
|
|
110
|
+
resp_headers = {k: v for k, v in resp.headers.items() if k.lower() not in DROP_RESPONSE_HEADERS}
|
|
111
|
+
streaming = "text/event-stream" in resp.headers.get("content-type", "")
|
|
112
|
+
detail["status"] = resp.status_code
|
|
113
|
+
detail["stream"] = streaming
|
|
114
|
+
|
|
115
|
+
async def relay():
|
|
116
|
+
observer = UsageObserver(streaming)
|
|
117
|
+
completed = False
|
|
118
|
+
try:
|
|
119
|
+
async for chunk in resp.aiter_raw():
|
|
120
|
+
if not resp.headers.get("content-encoding"):
|
|
121
|
+
observer.feed(chunk)
|
|
122
|
+
yield chunk
|
|
123
|
+
completed = True
|
|
124
|
+
finally:
|
|
125
|
+
await resp.aclose()
|
|
126
|
+
await client.aclose()
|
|
127
|
+
observer.finish()
|
|
128
|
+
usage = observer.usage
|
|
129
|
+
failed = resp.status_code >= 400 or observer.failed or not completed
|
|
130
|
+
async with session() as db:
|
|
131
|
+
await audit.record(db, tool="llm.call", decision="error" if failed else "allow",
|
|
132
|
+
agent=Agent(id=agent_id, name=agent_name), session_id=sid, patterns=[pattern],
|
|
133
|
+
detail={**detail, "response_complete": completed and not observer.failed,
|
|
134
|
+
**({"usage": usage} if usage else {})},
|
|
135
|
+
exit_code=resp.status_code, duration_ms=int((time.monotonic() - started) * 1000))
|
|
136
|
+
|
|
137
|
+
return StreamingResponse(relay(), status_code=resp.status_code, headers=resp_headers)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _usage(usage: Any) -> dict[str, Any] | None:
|
|
141
|
+
"""Token counts as the audit log keeps them. The field names lose their `_tokens` suffix so the
|
|
142
|
+
credential scrubber, which redacts anything called *token*, leaves the numbers alone."""
|
|
143
|
+
return numeric_usage(usage)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _key_for(db, agent: Agent, res: Resource, sid: str | None, model: str | None,
|
|
147
|
+
headers: dict[str, str]) -> tuple[LlmKey | None, JSONResponse | None]:
|
|
148
|
+
"""The key this call goes upstream with, or the JSON error explaining why there is none."""
|
|
149
|
+
if model is None:
|
|
150
|
+
# /v1/models and friends: any active lease will do; LiteLLM answers for that key's models.
|
|
151
|
+
# A chat UI lists models before its first call, so standing access mints its key here too.
|
|
152
|
+
keys = await _active_keys(db, agent, res)
|
|
153
|
+
if keys:
|
|
154
|
+
return keys[0], None
|
|
155
|
+
try:
|
|
156
|
+
return await _standing_key(db, agent, res, sid, None), None
|
|
157
|
+
except NoStandingAccess:
|
|
158
|
+
return None, _error(403, "no_lease", f"No active lease on {res.name}; ask for a model first.")
|
|
159
|
+
except llm.GatewayError as e:
|
|
160
|
+
return None, _error(503, "gateway_refused", f"The gateway would not mint a key: {e}")
|
|
161
|
+
pattern = f"llm/{res.name}/model/{model_segment(model)}"
|
|
162
|
+
operation = {"resource_id": res.id, "gateway": res.name, "model": model}
|
|
163
|
+
v = await policy.evaluate(db, agent, sid, [pattern], consume=False, operation=operation)
|
|
164
|
+
if not v.allowed:
|
|
165
|
+
if v.status == "deny":
|
|
166
|
+
await audit.record(db, tool="llm.call", decision="deny", agent=agent, session_id=sid,
|
|
167
|
+
patterns=[pattern], detail={"gateway": res.name, "model": model})
|
|
168
|
+
return None, _error(403, "denied", f"A standing deny rule covers {model}.")
|
|
169
|
+
if v.status == "locked":
|
|
170
|
+
await audit.record(db, tool="llm.call", decision="locked", agent=agent, session_id=sid,
|
|
171
|
+
patterns=[pattern], detail={"gateway": res.name, "model": model})
|
|
172
|
+
return None, _error(403, "locked", f"{res.name} is locked; only a rule in the admin UI opens it.")
|
|
173
|
+
reason = headers.get("x-keywright-reason") or f"LLM call: {model} via {res.name}"
|
|
174
|
+
try:
|
|
175
|
+
req, _ = await _request_or_reuse(db, agent, sid, v.missing, reason[:2000],
|
|
176
|
+
{"gateway": res.name, "model": model})
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
return None, _error(429, "too_many_requests", str(e))
|
|
179
|
+
if req is None:
|
|
180
|
+
await audit.record(db, tool="llm.call", decision="deny", agent=agent, session_id=sid,
|
|
181
|
+
patterns=[pattern], detail={"gateway": res.name, "model": model, "note": "recently denied"})
|
|
182
|
+
return None, _error(403, "denied", "Recently denied by the human; do not retry blindly.", retry_after=600)
|
|
183
|
+
wait_s = _wait_seconds(headers)
|
|
184
|
+
if wait_s:
|
|
185
|
+
await approvals.wait(req.id, wait_s)
|
|
186
|
+
await db.refresh(req)
|
|
187
|
+
if req.status == "approved":
|
|
188
|
+
v = await policy.evaluate(db, agent, sid, [pattern], consume=False, operation=operation)
|
|
189
|
+
if not v.allowed:
|
|
190
|
+
await audit.record(db, tool="llm.call", decision=req.status if req.status != "approved" else "pending",
|
|
191
|
+
agent=agent, session_id=sid, patterns=[pattern],
|
|
192
|
+
detail={"gateway": res.name, "model": model, "request_id": req.id})
|
|
193
|
+
if req.status == "pending":
|
|
194
|
+
return None, _error(403, "approval_required",
|
|
195
|
+
f"Approval for {model} is pending with the human (request {req.id}); "
|
|
196
|
+
"retry once it is decided.", request_id=req.id)
|
|
197
|
+
return None, _error(403, "denied", f"Request {req.id} was {req.status}.", request_id=req.id,
|
|
198
|
+
reason=req.deny_reason or None)
|
|
199
|
+
matched = v.matched.get(pattern, "")
|
|
200
|
+
if matched.startswith("grant:"):
|
|
201
|
+
grant_id = matched[6:]
|
|
202
|
+
for k in await _active_keys(db, agent, res):
|
|
203
|
+
if grant_id in (k.grant_ids or []) and model in (k.models or []):
|
|
204
|
+
return k, None
|
|
205
|
+
# Every lease is minted with its key; a lease without one is a bug or a hand-inserted grant.
|
|
206
|
+
log.error("grant %s allows %s but has no LiteLLM key", grant_id, pattern)
|
|
207
|
+
return None, _error(503, "no_key", "This lease has no gateway key; revoke it and approve again.")
|
|
208
|
+
try:
|
|
209
|
+
return await _standing_key(db, agent, res, sid, model), None
|
|
210
|
+
except llm.GatewayError as e:
|
|
211
|
+
await audit.record(db, tool="llm.call", decision="error", agent=agent, session_id=sid,
|
|
212
|
+
patterns=[pattern], detail={"gateway": res.name, "model": model, "error": str(e)})
|
|
213
|
+
return None, _error(503, "gateway_refused", f"The gateway would not mint a key: {e}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _wait_seconds(headers: dict[str, str]) -> int:
|
|
217
|
+
raw = headers.get("x-keywright-wait")
|
|
218
|
+
if raw and raw.isdigit():
|
|
219
|
+
return min(int(raw), settings.request_timeout_s)
|
|
220
|
+
return settings.llm_wait_s
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def _active_keys(db, agent: Agent, res: Resource) -> list[LlmKey]:
|
|
224
|
+
at = now()
|
|
225
|
+
rows = (await db.execute(select(LlmKey).where(
|
|
226
|
+
LlmKey.agent_id == agent.id, LlmKey.resource_id == res.id, LlmKey.revoked_at.is_(None))
|
|
227
|
+
.order_by(LlmKey.created_at.desc()))).scalars().all()
|
|
228
|
+
return [k for k in rows if k.is_active(at)]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class NoStandingAccess(Exception):
|
|
232
|
+
"""No allow rule or open default reaches any model on this gateway for this agent."""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def _standing_key(db, agent: Agent, res: Resource, sid: str | None, model: str | None) -> LlmKey:
|
|
236
|
+
"""The one key an agent's standing access on a gateway runs on: every model an allow rule or the
|
|
237
|
+
open default reaches, the gateway's per-lease ceiling, an hour at a time. Reused while it has
|
|
238
|
+
at least ten minutes left, so a long conversation does not mint a key per call."""
|
|
239
|
+
at = now()
|
|
240
|
+
for k in await _active_keys(db, agent, res):
|
|
241
|
+
if (not k.grant_ids and (model is None or model in (k.models or [])) and k.expires_at
|
|
242
|
+
and k.expires_at - at > timedelta(minutes=10)):
|
|
243
|
+
return k
|
|
244
|
+
models = []
|
|
245
|
+
for m, path in llm.model_paths(res).items():
|
|
246
|
+
v = await policy.evaluate(db, agent, sid, [path], consume=False,
|
|
247
|
+
operation={"resource_id": res.id, "gateway": res.name, "model": m})
|
|
248
|
+
if v.allowed and not v.matched.get(path, "").startswith("grant:"):
|
|
249
|
+
models.append(m)
|
|
250
|
+
if model is not None and model not in models:
|
|
251
|
+
models.append(model)
|
|
252
|
+
if not models:
|
|
253
|
+
raise NoStandingAccess(res.name)
|
|
254
|
+
health = await llm.readiness(res.config)
|
|
255
|
+
if not llm.enforces_budgets(health):
|
|
256
|
+
raise llm.GatewayError(f"LiteLLM reports its database {health['db']!r}; budgets would not be enforced")
|
|
257
|
+
expires = at + STANDING_TTL
|
|
258
|
+
limits = llm.limits_of(res.config)
|
|
259
|
+
await llm.ensure_user(res.config, agent)
|
|
260
|
+
alias = f"kw:{agent.name}:standing:{res.name}:{int(at.timestamp())}"[:100]
|
|
261
|
+
key = await llm.generate_key(res.config, agent, models, expires, limits, alias,
|
|
262
|
+
{"agent": agent.name, "standing": True, "models": models})
|
|
263
|
+
row = LlmKey(resource_id=res.id, agent_id=agent.id, request_id=None, grant_ids=[], key=key, key_alias=alias,
|
|
264
|
+
models=models, limits=limits, expires_at=expires)
|
|
265
|
+
db.add(row)
|
|
266
|
+
await db.commit()
|
|
267
|
+
await audit.record(db, tool="llm.key", decision="minted", agent=agent, session_id=sid,
|
|
268
|
+
patterns=[f"llm/{res.name}/model/{model_segment(m)}" for m in models],
|
|
269
|
+
detail={"key_alias": alias, "models": models, "limits": limits, "standing": True,
|
|
270
|
+
"expires_at": expires.isoformat()})
|
|
271
|
+
return row
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Bounded observation of response usage; never persists response content."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def numeric_usage(value: Any) -> dict | None:
|
|
10
|
+
if not isinstance(value, dict):
|
|
11
|
+
return None
|
|
12
|
+
out = {}
|
|
13
|
+
for name, count in value.items():
|
|
14
|
+
if (type(count) is int or type(count) is float and math.isfinite(count)) and count >= 0:
|
|
15
|
+
out[name.replace("_tokens", "")] = count
|
|
16
|
+
elif isinstance(count, dict):
|
|
17
|
+
nested = numeric_usage(count)
|
|
18
|
+
if nested:
|
|
19
|
+
out[name.replace("_tokens", "")] = nested
|
|
20
|
+
return out or None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UsageObserver:
|
|
24
|
+
"""Read JSON or SSE incrementally, retaining at most one bounded event/body.
|
|
25
|
+
|
|
26
|
+
Anthropic deltas and OpenAI usage chunks contain cumulative counts: replace
|
|
27
|
+
each reported field, never sum successive events for the same request.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
LIMIT = 1_000_000
|
|
31
|
+
|
|
32
|
+
def __init__(self, streaming: bool):
|
|
33
|
+
self.streaming = streaming
|
|
34
|
+
self.buffer = bytearray()
|
|
35
|
+
self.data: list[bytes] = []
|
|
36
|
+
self.size = 0
|
|
37
|
+
self.skipping = False
|
|
38
|
+
self.dropping_line = False
|
|
39
|
+
self.usage: dict = {}
|
|
40
|
+
self.failed = False
|
|
41
|
+
|
|
42
|
+
def observe(self, data: Any):
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
return
|
|
45
|
+
if data.get("type") in ("error", "response.failed") or data.get("error"):
|
|
46
|
+
self.failed = True
|
|
47
|
+
containers = [data]
|
|
48
|
+
for field in ("message", "response"):
|
|
49
|
+
if isinstance(data.get(field), dict):
|
|
50
|
+
containers.append(data[field])
|
|
51
|
+
for container in containers:
|
|
52
|
+
self.usage.update(numeric_usage(container.get("usage")) or {})
|
|
53
|
+
|
|
54
|
+
def parse(self, raw: bytes):
|
|
55
|
+
try:
|
|
56
|
+
self.observe(json.loads(raw))
|
|
57
|
+
except (ValueError, RecursionError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def feed(self, chunk: bytes):
|
|
61
|
+
if not self.streaming:
|
|
62
|
+
if not self.skipping and len(self.buffer) + len(chunk) <= self.LIMIT:
|
|
63
|
+
self.buffer.extend(chunk)
|
|
64
|
+
else:
|
|
65
|
+
self.skipping = True
|
|
66
|
+
self.buffer.clear()
|
|
67
|
+
return
|
|
68
|
+
# Split before retaining bytes so even one enormous chunk is bounded.
|
|
69
|
+
for index, part in enumerate(chunk.split(b"\n")):
|
|
70
|
+
if index:
|
|
71
|
+
if not self.dropping_line:
|
|
72
|
+
self.line(bytes(self.buffer).rstrip(b"\r"))
|
|
73
|
+
self.dropping_line = False
|
|
74
|
+
self.buffer.clear()
|
|
75
|
+
if len(self.buffer) + self.size + len(part) > self.LIMIT:
|
|
76
|
+
self.skipping = True
|
|
77
|
+
self.dropping_line = True
|
|
78
|
+
self.buffer.clear()
|
|
79
|
+
self.data.clear()
|
|
80
|
+
elif not self.dropping_line:
|
|
81
|
+
self.buffer.extend(part)
|
|
82
|
+
|
|
83
|
+
def line(self, line: bytes):
|
|
84
|
+
if not line:
|
|
85
|
+
if not self.skipping and self.data:
|
|
86
|
+
self.parse(b"\n".join(self.data))
|
|
87
|
+
self.data.clear()
|
|
88
|
+
self.size = 0
|
|
89
|
+
self.skipping = False
|
|
90
|
+
elif not self.skipping and line.startswith(b"data:"):
|
|
91
|
+
value = line[5:].removeprefix(b" ")
|
|
92
|
+
self.data.append(value)
|
|
93
|
+
self.size += len(value)
|
|
94
|
+
|
|
95
|
+
def finish(self):
|
|
96
|
+
if not self.streaming and not self.skipping and self.buffer:
|
|
97
|
+
self.parse(bytes(self.buffer))
|
|
98
|
+
self.buffer.clear()
|
|
99
|
+
self.data.clear()
|