forgeintel-sdk 0.1.0b1__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.
forgeintel/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Forge SDK for Python x402 merchants."""
2
+
3
+ from .asgi import ForgeMiddleware
4
+ from .core import AgentContextOptions, Forge, ForgeOptions, OpenAPIOptions
5
+
6
+ __version__ = "0.1.0b1"
7
+ __all__ = ["AgentContextOptions", "Forge", "ForgeMiddleware", "ForgeOptions", "OpenAPIOptions"]
forgeintel/asgi.py ADDED
@@ -0,0 +1,303 @@
1
+ """Pure ASGI adapter; no FastAPI/Starlette dependency and no BaseHTTPMiddleware.
2
+
3
+ Install outside payment middleware. Streamed, compressed and binary responses are
4
+ passed through; bounded JSON bodies can carry feedback alongside the merchant's data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from collections import deque
12
+ from urllib.parse import parse_qs
13
+
14
+ from .core import Forge, Reply
15
+ from .signals import take_query
16
+ from .wire import json_bytes
17
+
18
+ BODY_LIMIT = 1024 * 1024
19
+ SPEC_LIMIT = 5 * BODY_LIMIT
20
+ RATING_LIMIT = 16 * 1024
21
+ JSON_MEDIA = re.compile(r"^application/(?:[\w.+-]+\+)?json(?:\s*;|$)", re.I)
22
+ INVALIDATED = {
23
+ b"content-length",
24
+ b"etag",
25
+ b"content-md5",
26
+ b"digest",
27
+ b"content-digest",
28
+ b"repr-digest",
29
+ }
30
+
31
+
32
+ def header_map(headers: list) -> dict[str, str]:
33
+ # Preserve the first field, like Starlette Headers.get(). Raw lists are kept when forwarding.
34
+ result = {}
35
+ for key, value in headers:
36
+ result.setdefault(key.decode("latin-1").lower(), value.decode("latin-1"))
37
+ return result
38
+
39
+
40
+ def update_headers(headers: list, changes: dict[str, str], *, body: bytes | None = None) -> list:
41
+ remove = {key.encode("ascii") for key in changes}
42
+ if body is not None:
43
+ remove |= INVALIDATED
44
+ result = [(key, value) for key, value in headers if key.lower() not in remove]
45
+ result.extend((key.encode("ascii"), value.encode("latin-1")) for key, value in changes.items())
46
+ if body is not None:
47
+ result.append((b"content-length", str(len(body)).encode()))
48
+ return result
49
+
50
+
51
+ async def send_reply(reply: Reply, method: str, send) -> None:
52
+ body = b"" if reply.body is None else json_bytes(reply.body)
53
+ headers = {
54
+ "content-type": "application/json; charset=utf-8",
55
+ **reply.headers,
56
+ "content-length": str(len(body)),
57
+ }
58
+ await send(
59
+ {
60
+ "type": "http.response.start",
61
+ "status": reply.status,
62
+ "headers": [(k.encode(), v.encode("latin-1")) for k, v in headers.items()],
63
+ }
64
+ )
65
+ await send({"type": "http.response.body", "body": b"" if method == "HEAD" else body})
66
+
67
+
68
+ class ForgeMiddleware:
69
+ def __init__(self, app, *, forge: Forge):
70
+ self.app, self.forge = app, forge
71
+
72
+ async def __call__(self, scope, receive, send):
73
+ forge = self.forge
74
+ if not forge.enabled:
75
+ return await self.app(scope, receive, send)
76
+ if scope["type"] == "lifespan":
77
+
78
+ async def lifespan_send(message):
79
+ if message["type"] == "lifespan.shutdown.complete":
80
+ await forge.shutdown()
81
+ await send(message)
82
+
83
+ try:
84
+ await self.app(scope, receive, lifespan_send)
85
+ finally:
86
+ await forge.shutdown()
87
+ return
88
+ if scope["type"] != "http":
89
+ return await self.app(scope, receive, send)
90
+ method, path = scope["method"], scope["path"]
91
+ # ASGI servers differ on whether path retains root_path. Use the public path
92
+ # for telemetry, configured Forge routes and OpenAPI response-schema matching.
93
+ root = scope.get("root_path", "").rstrip("/")
94
+ local_path = (
95
+ path[len(root) :] if root and (path == root or path.startswith(root + "/")) else path
96
+ )
97
+ public_path = root + local_path
98
+ request_headers = header_map(scope.get("headers", []))
99
+ pending = deque()
100
+
101
+ async def replay():
102
+ return pending.popleft() if pending else await receive()
103
+
104
+ async def bounded_body(limit: int):
105
+ parts, total = [], 0
106
+ while True:
107
+ message = await receive()
108
+ pending.append(message)
109
+ if message["type"] != "http.request":
110
+ raise ValueError("request disconnected")
111
+ part = message.get("body", b"")
112
+ total += len(part)
113
+ if total > limit:
114
+ raise ValueError("body_too_large")
115
+ parts.append(part)
116
+ if not message.get("more_body", False):
117
+ return b"".join(parts)
118
+
119
+ async def read_rating():
120
+ return json.loads(await bounded_body(RATING_LIMIT))
121
+
122
+ try:
123
+ query = {
124
+ key: values[0]
125
+ for key, values in parse_qs(
126
+ scope.get("query_string", b"").decode("latin-1"), keep_blank_values=True
127
+ ).items()
128
+ }
129
+ own = await forge.route(method, public_path, request_headers, query, read_rating)
130
+ except Exception as error:
131
+ forge.on_error(error)
132
+ own = (
133
+ Reply(503, {"error": "feedback_unavailable"})
134
+ if forge.options.feedback
135
+ and public_path in (forge.base_path, forge.rate_path, forge.summary_path)
136
+ else None
137
+ )
138
+ if own is not None:
139
+ return await send_reply(own, method, send)
140
+
141
+ # Keep the original request replayable. Never catch exceptions from or retry the merchant app.
142
+ try:
143
+ call = forge.call(method, public_path, request_headers)
144
+ query_string, raw = take_query(scope.get("query_string", b""))
145
+ call.remember(raw)
146
+ forwarded = {**scope, "query_string": query_string}
147
+ except Exception as error:
148
+ forge.on_error(error)
149
+ return await self.app(scope, replay, send)
150
+ if JSON_MEDIA.match(request_headers.get("content-type", "")):
151
+ try:
152
+ if request_headers.get("content-encoding", "identity").lower() != "identity":
153
+ raise ValueError("encoded JSON is not inspected")
154
+ length = int(request_headers.get("content-length", "0"))
155
+ if length > BODY_LIMIT:
156
+ raise ValueError("body_too_large")
157
+ original = await bounded_body(BODY_LIMIT)
158
+ if original:
159
+ parsed = json.loads(original)
160
+ stripped = call.request_body(parsed)
161
+ if stripped is not parsed:
162
+ body = json_bytes(stripped)
163
+ forwarded["headers"] = update_headers(
164
+ scope.get("headers", []), {}, body=body
165
+ )
166
+ pending.clear()
167
+ pending.append({"type": "http.request", "body": body, "more_body": False})
168
+ except (ValueError, UnicodeError):
169
+ if call.context_required:
170
+ return await send_reply(
171
+ Reply(
172
+ 400,
173
+ {
174
+ "error": "agent_context_invalid",
175
+ "message": "Required agent context could not be read. Send valid JSON up to 1 MiB, or use agent_type and agent_search_query query parameters with a non-JSON body.",
176
+ },
177
+ ),
178
+ method,
179
+ send,
180
+ )
181
+ # Optional context on unreadable/large bodies is skipped. Every consumed byte is replayed.
182
+ except Exception as error:
183
+ forge.on_error(error)
184
+ if call.context_required:
185
+ return await send_reply(
186
+ Reply(400, {"error": "agent_context_invalid"}), method, send
187
+ )
188
+ error = call.context_error()
189
+ if error:
190
+ return await send_reply(error, method, send)
191
+
192
+ start = None
193
+ buffered = []
194
+ size = 0
195
+ should_buffer = False
196
+ sent_start = False
197
+ status = 0
198
+ response_headers = {}
199
+ spec = forge.is_spec(method, public_path)
200
+
201
+ async def response_send(message):
202
+ nonlocal start, size, should_buffer, sent_start, status, response_headers
203
+ kind = message["type"]
204
+ if kind == "http.response.start":
205
+ status = message["status"]
206
+ response_headers = header_map(message.get("headers", []))
207
+ start = {**message}
208
+ try:
209
+ changes = call.response_headers(status, response_headers)
210
+ start["headers"] = update_headers(message.get("headers", []), changes)
211
+ media = response_headers.get("content-type", "")
212
+ can_edit = bool(JSON_MEDIA.match(media)) or (
213
+ forge.options.inject_text and media.split(";", 1)[0].lower() == "text/plain"
214
+ )
215
+ limit = SPEC_LIMIT if spec else BODY_LIMIT
216
+ length = int(response_headers.get("content-length", "0"))
217
+ should_buffer = (
218
+ method != "HEAD"
219
+ and status not in (204, 205, 206, 304)
220
+ and response_headers.get("content-encoding", "identity").lower()
221
+ == "identity"
222
+ and "content-range" not in response_headers
223
+ and can_edit
224
+ and 0 <= length <= limit
225
+ and (
226
+ spec
227
+ and status == 200
228
+ or status == 402
229
+ or bool(call.feedback_id)
230
+ and 200 <= status < 300
231
+ )
232
+ )
233
+ except Exception as error:
234
+ forge.on_error(error)
235
+ should_buffer = False
236
+ if not should_buffer:
237
+ await send(start)
238
+ sent_start = True
239
+ return
240
+ if kind != "http.response.body":
241
+ if start and not sent_start:
242
+ await send(start)
243
+ sent_start = True
244
+ for chunk in buffered:
245
+ await send(chunk)
246
+ buffered.clear()
247
+ should_buffer = False
248
+ return await send(message)
249
+ if should_buffer:
250
+ buffered.append(message)
251
+ size += len(message.get("body", b""))
252
+ limit = SPEC_LIMIT if spec else BODY_LIMIT
253
+ # Unknown-length streams must keep their streaming behavior.
254
+ if size > limit or (
255
+ message.get("more_body", False) and "content-length" not in response_headers
256
+ ):
257
+ should_buffer = False
258
+ await send(start)
259
+ sent_start = True
260
+ for chunk in buffered:
261
+ await send(chunk)
262
+ buffered.clear()
263
+ elif not message.get("more_body", False):
264
+ original = b"".join(chunk.get("body", b"") for chunk in buffered)
265
+ body = original
266
+ try:
267
+ media = response_headers.get("content-type", "")
268
+ if JSON_MEDIA.match(media):
269
+ parsed = json.loads(original)
270
+ decorated = (
271
+ forge.enrich_openapi(parsed)
272
+ if spec and status == 200
273
+ else call.response_json(status, parsed)
274
+ )
275
+ if decorated != parsed:
276
+ body = json_bytes(decorated)
277
+ elif call.feedback_id and forge.options.inject_text and 200 <= status < 300:
278
+ text = original.decode("utf-8")
279
+ body = (
280
+ text
281
+ + ("" if text.endswith("\n") else "\n")
282
+ + f"\nfeedback_id: {call.feedback_id}\nfeedback_url: {call.feedback_url}\n"
283
+ ).encode()
284
+ if body != original:
285
+ start["headers"] = update_headers(
286
+ start.get("headers", []), {}, body=body
287
+ )
288
+ except Exception as error:
289
+ forge.on_error(error)
290
+ body = original
291
+ await send(start)
292
+ sent_start = True
293
+ await send({**message, "body": body})
294
+ buffered.clear()
295
+ else:
296
+ await send(message)
297
+ if not message.get("more_body", False):
298
+ try:
299
+ call.finish(status)
300
+ except Exception as error:
301
+ forge.on_error(error)
302
+
303
+ await self.app(forwarded, replay, response_send)