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 +7 -0
- forgeintel/asgi.py +303 -0
- forgeintel/core.py +616 -0
- forgeintel/ids.py +44 -0
- forgeintel/openapi.py +363 -0
- forgeintel/py.typed +0 -0
- forgeintel/reporter.py +101 -0
- forgeintel/signals.py +147 -0
- forgeintel/wire.py +163 -0
- forgeintel_sdk-0.1.0b1.dist-info/METADATA +160 -0
- forgeintel_sdk-0.1.0b1.dist-info/RECORD +13 -0
- forgeintel_sdk-0.1.0b1.dist-info/WHEEL +4 -0
- forgeintel_sdk-0.1.0b1.dist-info/licenses/LICENSE +21 -0
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)
|