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,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()