zeambridge 2.0.0__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.
- zeam_bridge/__init__.py +1233 -0
- zeam_bridge/engine/__init__.py +5 -0
- zeam_bridge/engine/abi.py +266 -0
- zeam_bridge/engine/batch_settlement.py +334 -0
- zeam_bridge/engine/bazaar.py +115 -0
- zeam_bridge/engine/core.py +285 -0
- zeam_bridge/engine/discovery.py +76 -0
- zeam_bridge/engine/env.py +27 -0
- zeam_bridge/engine/files.py +82 -0
- zeam_bridge/engine/gate/__init__.py +9 -0
- zeam_bridge/engine/gate/admission.py +152 -0
- zeam_bridge/engine/gate/credits.py +159 -0
- zeam_bridge/engine/gate/exact.py +189 -0
- zeam_bridge/engine/gate/http.py +17 -0
- zeam_bridge/engine/gate/jsonx.py +121 -0
- zeam_bridge/engine/gate/meter.py +92 -0
- zeam_bridge/engine/gate/store.py +48 -0
- zeam_bridge/engine/keys.py +131 -0
- zeam_bridge/engine/messages.py +111 -0
- zeam_bridge/engine/meter.py +563 -0
- zeam_bridge/engine/php.py +157 -0
- zeam_bridge/engine/pricing.py +282 -0
- zeam_bridge/engine/rpc.py +74 -0
- zeam_bridge/engine/runtime.py +684 -0
- zeam_bridge/engine/settlement/__init__.py +18 -0
- zeam_bridge/engine/settlement/cache.py +82 -0
- zeam_bridge/engine/settlement/chain.py +97 -0
- zeam_bridge/engine/settlement/channels.py +342 -0
- zeam_bridge/engine/settlement/claims.py +159 -0
- zeam_bridge/engine/settlement/config.py +193 -0
- zeam_bridge/engine/settlement/gas_quote.py +147 -0
- zeam_bridge/engine/settlement/jsonx.py +70 -0
- zeam_bridge/engine/settlement/mpp.py +505 -0
- zeam_bridge/engine/settlement/payout.py +52 -0
- zeam_bridge/engine/settlement/reason.py +33 -0
- zeam_bridge/engine/settlement/refunds.py +780 -0
- zeam_bridge/engine/settlement/relay.py +172 -0
- zeam_bridge/engine/settlement/server.py +924 -0
- zeam_bridge/engine/settlement/store.py +184 -0
- zeam_bridge/engine/settlement/verify.py +450 -0
- zeam_bridge/engine/settlement/withdrawals.py +44 -0
- zeam_bridge/engine/splits.py +116 -0
- zeam_bridge/engine/typed_data.py +88 -0
- zeam_bridge/engine/wire.py +9 -0
- zeam_bridge/grants.py +68 -0
- zeam_bridge/old_name.py +14 -0
- zeam_pass/__init__.py +5 -0
- zeam_pass.py +5 -0
- zeambridge-2.0.0.dist-info/METADATA +344 -0
- zeambridge-2.0.0.dist-info/RECORD +54 -0
- zeambridge-2.0.0.dist-info/WHEEL +5 -0
- zeambridge-2.0.0.dist-info/licenses/LICENSE +202 -0
- zeambridge-2.0.0.dist-info/licenses/NOTICE +4 -0
- zeambridge-2.0.0.dist-info/top_level.txt +2 -0
zeam_bridge/__init__.py
ADDED
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import contextvars
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
import functools
|
|
6
|
+
import http
|
|
7
|
+
import inspect
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import threading
|
|
14
|
+
import urllib.parse
|
|
15
|
+
|
|
16
|
+
from .engine import DEFAULT_CREDITS, DEFAULT_RELAY, DEFAULT_RPC, GRANTED_BY, Engine, setting
|
|
17
|
+
from .engine.bazaar import bazaar
|
|
18
|
+
from .engine.discovery import CACHE_CONTROL, discovery
|
|
19
|
+
from .engine.meter import bought_ms, time_micro, time_rate, time_text
|
|
20
|
+
from .engine.pricing import FreeLimit, Hangup, caller, deadline_ms, hearing, metered, price_of, running, stopped, unhearing, units, usd
|
|
21
|
+
from .engine.pricing import describe as describe_price
|
|
22
|
+
from .engine.settlement import Channels, StateFile
|
|
23
|
+
from .engine.settlement.mpp import Credential, McpPayment, MppError, Receipt
|
|
24
|
+
from .engine.wire import stringify
|
|
25
|
+
from .grants import gate_proof, key_address, new_key, sign_grant
|
|
26
|
+
|
|
27
|
+
META_PAYMENT = "x402/payment"
|
|
28
|
+
META_RESPONSE = "x402/payment-response"
|
|
29
|
+
META_GRANTED_BY = "zeam-pass/granted-by"
|
|
30
|
+
META_PRICE = "zeam-pass/price"
|
|
31
|
+
META_LINE = "zeam-pass/line"
|
|
32
|
+
META_METER = "zeam-pass/meter"
|
|
33
|
+
LINE_OPS = ("open", "prove", "on", "off", "status", "close")
|
|
34
|
+
CHANNEL = re.compile(r"^0x[0-9a-fA-F]{64}$")
|
|
35
|
+
MCP_SESSION = re.compile(r"^mcp\.[A-Za-z0-9_-]{16}\Z")
|
|
36
|
+
MCP_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
37
|
+
MAX_BODY = 1024 * 1024
|
|
38
|
+
PROBLEM_JSON = "application/problem+json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _problem(payload):
|
|
42
|
+
return isinstance(payload, dict) and isinstance(payload.get("type"), str) and payload["type"].startswith("https://paymentauth.org/problems/")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CORS = {"access-control-allow-origin": "*", "access-control-expose-headers": "PAYMENT-REQUIRED, PAYMENT-RESPONSE, WWW-Authenticate, Payment-Receipt, X-Pass-Granted-By, X-Pass-Ms-Remaining, X-Pass-Ms-Elapsed"}
|
|
46
|
+
PREFLIGHT = {**CORS, "access-control-allow-methods": "GET, POST, OPTIONS",
|
|
47
|
+
"access-control-allow-headers": "content-type, payment-signature, x-payment, authorization, payment-authorization, x-line, x-grant, mcp-protocol-version, mcp-session-id",
|
|
48
|
+
"access-control-max-age": "600"}
|
|
49
|
+
HOW = {"paywall": "Paid per call with x402 or MPP", "gate": "Admitted keys only, proven by a zero-value x402 signature; nothing is paid",
|
|
50
|
+
"both": "Admitted keys only, paid per call with x402 or MPP"}
|
|
51
|
+
RESULT_NOT_JSON = "the result is not valid JSON (a non-finite number or a non-JSON value); nothing was charged"
|
|
52
|
+
TYPES = {"string": (str,), "integer": (int,), "number": (int, float), "boolean": (bool,),
|
|
53
|
+
"object": (dict,), "array": (list,), "null": (type(None),)}
|
|
54
|
+
|
|
55
|
+
__all__ = ["Bridge", "Pass", "Engine", "Hangup", "deadline_ms", "gate_proof", "key_address", "new_key", "setting", "sign_grant", "stopped", "units"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _b64(obj):
|
|
59
|
+
return base64.b64encode(json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode()).decode()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _unb64(value):
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(base64.b64decode(value).decode())
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _reject_constant(name):
|
|
70
|
+
raise ValueError(f"{name} is not a JSON number")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _finite(text):
|
|
74
|
+
value = float(text)
|
|
75
|
+
if not math.isfinite(value):
|
|
76
|
+
raise ValueError(f"{text} is out of range")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _faithful(value):
|
|
81
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
82
|
+
return True
|
|
83
|
+
if isinstance(value, float):
|
|
84
|
+
return math.isfinite(value)
|
|
85
|
+
if isinstance(value, (list, tuple)):
|
|
86
|
+
return all(_faithful(v) for v in value)
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
return all(isinstance(k, str) and _faithful(v) for k, v in value.items())
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _serializes(value):
|
|
93
|
+
try:
|
|
94
|
+
json.dumps(value, allow_nan=False)
|
|
95
|
+
except (TypeError, ValueError, RecursionError):
|
|
96
|
+
return False
|
|
97
|
+
return _faithful(value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _loads(raw):
|
|
101
|
+
return json.loads(raw, parse_constant=_reject_constant, parse_float=_finite)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is(value, kind):
|
|
105
|
+
if kind == "integer" and isinstance(value, float):
|
|
106
|
+
return value.is_integer()
|
|
107
|
+
if isinstance(value, bool) and kind != "boolean":
|
|
108
|
+
return False
|
|
109
|
+
return isinstance(value, TYPES.get(kind, (object,)))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _number(value):
|
|
113
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_value(key, spec, value):
|
|
117
|
+
kinds = spec.get("type")
|
|
118
|
+
kinds = [kinds] if isinstance(kinds, str) else kinds if isinstance(kinds, list) else []
|
|
119
|
+
if kinds and not any(_is(value, k) for k in kinds):
|
|
120
|
+
return f"argument {key} must be {' or '.join(map(str, kinds))}"
|
|
121
|
+
if isinstance(spec.get("enum"), list) and value not in spec["enum"]:
|
|
122
|
+
return f"argument {key} must be one of {stringify(spec['enum'])}"
|
|
123
|
+
if _number(value):
|
|
124
|
+
if _number(spec.get("minimum")) and value < spec["minimum"]:
|
|
125
|
+
return f"argument {key} must be at least {spec['minimum']}"
|
|
126
|
+
if _number(spec.get("maximum")) and value > spec["maximum"]:
|
|
127
|
+
return f"argument {key} must be at most {spec['maximum']}"
|
|
128
|
+
if _number(spec.get("multipleOf")) and spec["multipleOf"] > 0 and value % spec["multipleOf"] != 0:
|
|
129
|
+
return f"argument {key} must be a multiple of {spec['multipleOf']}"
|
|
130
|
+
if isinstance(value, str):
|
|
131
|
+
if _number(spec.get("minLength")) and len(value) < spec["minLength"]:
|
|
132
|
+
return f"argument {key} must be at least {spec['minLength']} characters"
|
|
133
|
+
if _number(spec.get("maxLength")) and len(value) > spec["maxLength"]:
|
|
134
|
+
return f"argument {key} must be at most {spec['maxLength']} characters"
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _check_object(schema, args, prefix, nested):
|
|
139
|
+
required = schema.get("required") if isinstance(schema.get("required"), list) else []
|
|
140
|
+
missing = [k for k in required if k not in args]
|
|
141
|
+
if missing:
|
|
142
|
+
return f"missing required argument: {prefix}{missing[0]}"
|
|
143
|
+
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
|
144
|
+
for key, value in args.items():
|
|
145
|
+
spec = props.get(key)
|
|
146
|
+
if not isinstance(spec, dict):
|
|
147
|
+
continue
|
|
148
|
+
wrong = _check_value(prefix + str(key), spec, value)
|
|
149
|
+
if wrong:
|
|
150
|
+
return wrong
|
|
151
|
+
if nested and isinstance(value, dict):
|
|
152
|
+
wrong = _check_object(spec, value, f"{prefix}{key}.", False)
|
|
153
|
+
if wrong:
|
|
154
|
+
return wrong
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _accepted(fn, args):
|
|
159
|
+
try:
|
|
160
|
+
params = inspect.signature(fn).parameters.values()
|
|
161
|
+
except (TypeError, ValueError):
|
|
162
|
+
return args
|
|
163
|
+
if any(p.kind == p.VAR_KEYWORD for p in params):
|
|
164
|
+
return args
|
|
165
|
+
names = {p.name for p in params if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)}
|
|
166
|
+
return {k: v for k, v in args.items() if k in names}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _validate(schema, args):
|
|
170
|
+
if not isinstance(args, dict):
|
|
171
|
+
return "the arguments must be an object"
|
|
172
|
+
return _check_object(schema if isinstance(schema, dict) else {}, args, "", True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _said(out):
|
|
176
|
+
content = out.get("content") if isinstance(out, dict) else None
|
|
177
|
+
texts = [c["text"] for c in content if isinstance(c, dict) and isinstance(c.get("text"), str)] if isinstance(content, list) else []
|
|
178
|
+
return "\n".join(texts) or "the tool reported an error"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _text(obj):
|
|
182
|
+
return [{"type": "text", "text": obj if isinstance(obj, str) else stringify(obj)}]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _reason(status):
|
|
186
|
+
try:
|
|
187
|
+
return http.HTTPStatus(status).phrase
|
|
188
|
+
except ValueError:
|
|
189
|
+
return "Status"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _first(headers, names):
|
|
193
|
+
for n in names:
|
|
194
|
+
v = headers.get(n)
|
|
195
|
+
if isinstance(v, str) and v.strip() != "":
|
|
196
|
+
return v.strip()
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _out_of_time(on_line, ms=None):
|
|
201
|
+
if on_line:
|
|
202
|
+
return {"error": "out_of_time", "message": "the line ran out of time during the call. Its time is spent. Buy time: buy_time."}
|
|
203
|
+
return {"error": "out_of_time", "message": f"the call ran past the {ms} ms its price buys. Nothing was charged. Buy time and call on a line."}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _hung_up(on_line):
|
|
207
|
+
billed = "The line is billed for the time the call ran, nothing after." if on_line else "Nothing was charged."
|
|
208
|
+
return {"error": "client_gone", "message": f"the caller hung up or canceled the call, so it was stopped. {billed}"}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _call_key(ident):
|
|
212
|
+
if isinstance(ident, str):
|
|
213
|
+
return "string", ident
|
|
214
|
+
if isinstance(ident, (int, float)) and not isinstance(ident, bool):
|
|
215
|
+
return "number", float(ident)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _int(value):
|
|
220
|
+
try:
|
|
221
|
+
return int(float(value)) if value is not None and not isinstance(value, bool) else 0
|
|
222
|
+
except (TypeError, ValueError, OverflowError):
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _reading(meta):
|
|
227
|
+
return {"x-pass-ms-remaining": str(meta["msRemaining"]), "x-pass-ms-elapsed": str(meta["elapsedMs"])}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _metered(out, meta):
|
|
231
|
+
if not meta:
|
|
232
|
+
return out
|
|
233
|
+
return {**out, "_meta": {**(out["_meta"] if isinstance(out.get("_meta"), dict) else {}), META_METER: meta}}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _failure(body):
|
|
237
|
+
return {"isError": True, "structuredContent": body, "content": _text(body)}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _own_status(body):
|
|
241
|
+
s = body.get("status") if isinstance(body, dict) else None
|
|
242
|
+
return s if isinstance(s, int) and not isinstance(s, bool) and 400 <= s <= 599 and s != 402 else 500
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _failed(out, tool):
|
|
246
|
+
if isinstance(out.get("structuredContent"), dict):
|
|
247
|
+
return out
|
|
248
|
+
return {**out, "isError": True, "structuredContent": {"error": "tool_failed", "tool": tool, "message": _said(out)}}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _settled_meta(headers):
|
|
252
|
+
meta = {}
|
|
253
|
+
settled = _unb64(headers["PAYMENT-RESPONSE"]) if headers.get("PAYMENT-RESPONSE") else None
|
|
254
|
+
if settled:
|
|
255
|
+
meta[META_RESPONSE] = settled
|
|
256
|
+
if isinstance(headers.get(GRANTED_BY), str):
|
|
257
|
+
meta[META_GRANTED_BY] = headers[GRANTED_BY]
|
|
258
|
+
if isinstance(headers.get("Payment-Receipt"), str):
|
|
259
|
+
try:
|
|
260
|
+
meta[McpPayment.RECEIPT] = Receipt.parse(headers["Payment-Receipt"])
|
|
261
|
+
except MppError:
|
|
262
|
+
pass
|
|
263
|
+
return meta
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _mpp_credential(headers):
|
|
267
|
+
offered = _first(headers, ("payment-authorization", "authorization"))
|
|
268
|
+
return offered if Credential.is_one(offered) else ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _mpp_meta(s):
|
|
272
|
+
try:
|
|
273
|
+
required = McpPayment.required(s["headers"].get("WWW-Authenticate"), s["body"])
|
|
274
|
+
except MppError:
|
|
275
|
+
required = None
|
|
276
|
+
return {} if required is None else {"_meta": {McpPayment.REQUIRED: required}}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class _RpcError(Exception):
|
|
280
|
+
def __init__(self, code, message):
|
|
281
|
+
super().__init__(message)
|
|
282
|
+
self.code = code
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class Bridge:
|
|
286
|
+
|
|
287
|
+
def __init__(self, name, payout=None, mode="paywall", price=None, site=None, admit=None, relay=DEFAULT_RELAY,
|
|
288
|
+
credits=DEFAULT_CREDITS, rpc=DEFAULT_RPC, state_dir=None, server_name=None, version="2.0.0", on_empty="refuse",
|
|
289
|
+
fee_recipient=None, credit_issuer=None, refund_url=None, tick_seconds=60, relay_transport=None,
|
|
290
|
+
credits_http=None, settings=None, contact=None, payout_is_fee_recipient=False, prices=None, free=None, free_limit=None, time=None):
|
|
291
|
+
self.name = name
|
|
292
|
+
self.server_name = server_name or name
|
|
293
|
+
self.version = version
|
|
294
|
+
self.site = site.rstrip("/") if site else None
|
|
295
|
+
self.engine = Engine(name, payout, mode=mode, price=price, site=self.site, admit=admit, relay=relay, credits=credits,
|
|
296
|
+
rpc=rpc, state_dir=state_dir, fee_recipient=fee_recipient, credit_issuer=credit_issuer,
|
|
297
|
+
on_empty=on_empty, refund_url=refund_url, relay_transport=relay_transport,
|
|
298
|
+
credits_http=credits_http, settings=settings, tick_seconds=tick_seconds, contact=contact,
|
|
299
|
+
payout_is_fee_recipient=payout_is_fee_recipient, prices=prices, free=free, free_limit=free_limit, time=time)
|
|
300
|
+
self._free_limit = FreeLimit(self.engine.free_limit, self.engine._now_ms)
|
|
301
|
+
self._free_file = StateFile(self.engine.state_dir, "free.json") if self.engine.free_limit else None
|
|
302
|
+
self._tools = {}
|
|
303
|
+
self._calling = {}
|
|
304
|
+
self._calls = threading.Lock()
|
|
305
|
+
self.time = self.engine.time
|
|
306
|
+
self.meter = self.engine.meter
|
|
307
|
+
if self.time:
|
|
308
|
+
self._builtins()
|
|
309
|
+
self.engine.start()
|
|
310
|
+
|
|
311
|
+
def _builtins(self):
|
|
312
|
+
t = self.time
|
|
313
|
+
meter = self.meter
|
|
314
|
+
|
|
315
|
+
def buy_time(ms=None, blocks=None):
|
|
316
|
+
n = bought_ms(t, {"ms": ms, "blocks": blocks})
|
|
317
|
+
box = running() or {}
|
|
318
|
+
cid = box.get("channelId")
|
|
319
|
+
st = meter.status(cid)
|
|
320
|
+
return {"channelId": cid, "boughtMs": n, "msRemaining": st["msRemaining"] + n,
|
|
321
|
+
"paidUSD": usd(time_micro(t, n))}
|
|
322
|
+
|
|
323
|
+
def line(**args):
|
|
324
|
+
return self._line_tool(args, "")
|
|
325
|
+
|
|
326
|
+
self._tools["buy_time"] = {
|
|
327
|
+
"name": "buy_time", "builtin": "buy_time", "run": buy_time, "price": None, "unit": None, "free": False,
|
|
328
|
+
"description": f"Buys line time: {time_rate(t)}, for the channel that pays. Then open a line.",
|
|
329
|
+
"inputSchema": {"type": "object", "properties": {"ms": {
|
|
330
|
+
"type": "integer", "minimum": t["rateMs"], **({"multipleOf": t["rateMs"]} if t["rateMs"] > 1 else {}), **({"maximum": t["maxMs"]} if t.get("maxMs") else {}),
|
|
331
|
+
"description": f"milliseconds of line time; default {t['unitMs']}. Buying again adds time"}}},
|
|
332
|
+
}
|
|
333
|
+
self._tools["line"] = {
|
|
334
|
+
"name": "line", "builtin": "line", "run": line, "price": None, "unit": None, "free": True,
|
|
335
|
+
"description": ("A line spends bought time without a payment per call. op: open {channelId} returns a message to sign "
|
|
336
|
+
"with the payer key; prove {channelId, nonce, signature} returns the credential; on, off, status, close {credential}."),
|
|
337
|
+
"inputSchema": {"type": "object", "properties": {"op": {"enum": list(LINE_OPS)}, "channelId": {"type": "string"}, "nonce": {"type": "string"},
|
|
338
|
+
"signature": {"type": "string"}, "credential": {"type": "string"}}, "required": ["op"]},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def mode(self):
|
|
343
|
+
return self.engine.mode
|
|
344
|
+
|
|
345
|
+
def tick(self):
|
|
346
|
+
return self.engine.tick()
|
|
347
|
+
|
|
348
|
+
def status(self):
|
|
349
|
+
return self.engine.status()
|
|
350
|
+
|
|
351
|
+
def payout_now(self):
|
|
352
|
+
return self.engine.payout_now()
|
|
353
|
+
|
|
354
|
+
def tool(self, name=None, description="", input_schema=None, price=None, unit=None, free=False, meter=None, output_schema=None):
|
|
355
|
+
def register(fn):
|
|
356
|
+
t = {
|
|
357
|
+
"name": name or fn.__name__,
|
|
358
|
+
"description": description or (fn.__doc__ or "").strip(),
|
|
359
|
+
"inputSchema": input_schema or {"type": "object"},
|
|
360
|
+
"run": fn,
|
|
361
|
+
"price": price,
|
|
362
|
+
"unit": unit,
|
|
363
|
+
"free": free is True,
|
|
364
|
+
}
|
|
365
|
+
if isinstance(output_schema, dict):
|
|
366
|
+
t["outputSchema"] = output_schema
|
|
367
|
+
if (self._tools.get(t["name"]) or {}).get("builtin"):
|
|
368
|
+
raise ValueError(f"bridge: {t['name']} is a built-in tool")
|
|
369
|
+
if meter is not None and meter != "time":
|
|
370
|
+
raise ValueError('bridge: meter is "time"')
|
|
371
|
+
if meter == "time":
|
|
372
|
+
if not self.time or self._is_free(t):
|
|
373
|
+
raise ValueError(f"bridge: {t['name']} meters time; set the seller's time option, and do not make it free")
|
|
374
|
+
t["meter"] = "time"
|
|
375
|
+
if not self._is_free(t) and self.mode != "gate" and (self._own_price(t) or not callable(self.engine.prices)):
|
|
376
|
+
self._price_for(t)
|
|
377
|
+
self._tools[t["name"]] = t
|
|
378
|
+
return fn
|
|
379
|
+
return register
|
|
380
|
+
|
|
381
|
+
def _is_free(self, tool):
|
|
382
|
+
return tool.get("free") is True or tool["name"] in self.engine.free
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _own_price(tool):
|
|
386
|
+
return tool.get("price") is not None or tool.get("unit") is not None
|
|
387
|
+
|
|
388
|
+
def _asked(self, tool, args):
|
|
389
|
+
if tool.get("builtin") != "buy_time" or not isinstance(args, dict) or "ms" in args:
|
|
390
|
+
return args
|
|
391
|
+
blocks = args.get("blocks")
|
|
392
|
+
if isinstance(blocks, float) and blocks.is_integer():
|
|
393
|
+
blocks = int(blocks)
|
|
394
|
+
if isinstance(blocks, int) and not isinstance(blocks, bool) and 1 <= blocks <= 9007199254740991:
|
|
395
|
+
return {**args, "ms": blocks * self.time["unitMs"]}
|
|
396
|
+
return args
|
|
397
|
+
|
|
398
|
+
def _price_for(self, tool, args=None):
|
|
399
|
+
if tool.get("builtin") == "buy_time":
|
|
400
|
+
ms = bought_ms(self.time, args)
|
|
401
|
+
return {"micro": time_micro(self.time, ms), "unitMicro": None, "ms": ms}
|
|
402
|
+
fallback = self.engine.price_micro
|
|
403
|
+
prices = self.engine.prices
|
|
404
|
+
if self._own_price(tool):
|
|
405
|
+
return price_of({"price": tool.get("price"), "unit": tool.get("unit")}, fallback)
|
|
406
|
+
if callable(prices):
|
|
407
|
+
return price_of(prices(tool["name"], args if args is not None else {}), fallback)
|
|
408
|
+
return price_of(prices.get(tool["name"]) if isinstance(prices, dict) else None, fallback)
|
|
409
|
+
|
|
410
|
+
def _price_tag(self, tool):
|
|
411
|
+
if tool.get("builtin") == "line":
|
|
412
|
+
return {"usd": "0", "per": "call", "free": True}
|
|
413
|
+
if tool.get("builtin") == "buy_time":
|
|
414
|
+
return {"usd": usd(self.time["rateMicro"]), "per": "ms", "ms": self.time["rateMs"], **({"maxMs": self.time["maxMs"]} if self.time.get("maxMs") else {})}
|
|
415
|
+
if tool.get("meter") == "time" and not (callable(self.engine.prices) and not self._own_price(tool)):
|
|
416
|
+
p = self._price_for(tool)
|
|
417
|
+
return {"per": "time", "usd": usd(self.time["rateMicro"]), "ms": self.time["rateMs"],
|
|
418
|
+
"callUSD": usd(p["micro"]), "callMs": self.meter.call_ms(p["micro"])}
|
|
419
|
+
if self._is_free(tool):
|
|
420
|
+
return describe_price(None, free=True, per_hour=self.engine.free_limit)
|
|
421
|
+
if self.mode == "gate":
|
|
422
|
+
return {"usd": "0", "per": "call"}
|
|
423
|
+
if not self._own_price(tool) and callable(self.engine.prices):
|
|
424
|
+
return describe_price(None, varies=True)
|
|
425
|
+
return describe_price(self._price_for(tool))
|
|
426
|
+
|
|
427
|
+
def _listing(self, base=""):
|
|
428
|
+
if self.mode == "gate":
|
|
429
|
+
return {}
|
|
430
|
+
out = {"prices": {t["name"]: self._price_tag(t) for t in self._tools.values()}}
|
|
431
|
+
if self.time:
|
|
432
|
+
out["time"] = time_text(self.time, base)
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
def tools(self):
|
|
436
|
+
return [self._describe(t) for t in self._tools.values()]
|
|
437
|
+
|
|
438
|
+
def _describe(self, tool):
|
|
439
|
+
return {"name": tool["name"], "description": tool["description"], "inputSchema": tool["inputSchema"], "_meta": {META_PRICE: self._price_tag(tool)}}
|
|
440
|
+
|
|
441
|
+
def _public(self, url):
|
|
442
|
+
if not self.site or not url:
|
|
443
|
+
return url
|
|
444
|
+
called, site = urllib.parse.urlsplit(url), urllib.parse.urlsplit(self.site)
|
|
445
|
+
return urllib.parse.urlunsplit((site.scheme, site.netloc, called.path, called.query, ""))
|
|
446
|
+
|
|
447
|
+
def _run(self, tool, args):
|
|
448
|
+
try:
|
|
449
|
+
out = tool["run"](**_accepted(tool["run"], args))
|
|
450
|
+
except TypeError as e:
|
|
451
|
+
return False, f"bad arguments: {e}", 400
|
|
452
|
+
except Exception as e:
|
|
453
|
+
return False, str(e), 500
|
|
454
|
+
if isinstance(out, dict) and out.get("isError") is True:
|
|
455
|
+
return False, out, 500
|
|
456
|
+
if not _serializes(out):
|
|
457
|
+
return False, RESULT_NOT_JSON, 500
|
|
458
|
+
return True, out, None
|
|
459
|
+
|
|
460
|
+
def _within(self, ms, tool, args, more=None):
|
|
461
|
+
result = {}
|
|
462
|
+
ctx = contextvars.copy_context()
|
|
463
|
+
mine = Hangup(caller())
|
|
464
|
+
ctx.run(hearing, mine)
|
|
465
|
+
wake = threading.Event()
|
|
466
|
+
|
|
467
|
+
def work():
|
|
468
|
+
try:
|
|
469
|
+
result["out"] = self._run(tool, args)
|
|
470
|
+
finally:
|
|
471
|
+
wake.set()
|
|
472
|
+
|
|
473
|
+
mine.listen(wake.set)
|
|
474
|
+
if not mine.gone:
|
|
475
|
+
threading.Thread(target=ctx.run, args=(work,), name=f"zeam-bridge-{tool['name']}", daemon=True).start()
|
|
476
|
+
while not mine.gone:
|
|
477
|
+
wake.wait(None if ms is None else min(max(0, ms) / 1000, threading.TIMEOUT_MAX))
|
|
478
|
+
if wake.is_set():
|
|
479
|
+
break
|
|
480
|
+
ms = more() if more is not None else 0
|
|
481
|
+
if not ms > 0:
|
|
482
|
+
break
|
|
483
|
+
mine.leave()
|
|
484
|
+
out = result.get("out")
|
|
485
|
+
if out is None:
|
|
486
|
+
mine.hang_up()
|
|
487
|
+
return out
|
|
488
|
+
|
|
489
|
+
@staticmethod
|
|
490
|
+
def _gone(out):
|
|
491
|
+
heard = caller()
|
|
492
|
+
return heard is not None and heard.gone and (out is None or not out[0])
|
|
493
|
+
|
|
494
|
+
def _bounded(self, ms, tool, args):
|
|
495
|
+
deadline = None if ms is None else self.engine._now_ms() + ms
|
|
496
|
+
box = running()
|
|
497
|
+
if box is not None and deadline is not None:
|
|
498
|
+
box["deadline"] = deadline
|
|
499
|
+
out = self._within(ms, tool, args)
|
|
500
|
+
if self._gone(out):
|
|
501
|
+
return False, {**_hung_up(False), "tool": tool["name"]}, 499
|
|
502
|
+
if deadline is not None and (out is None or (not out[0] and self.engine._now_ms() >= deadline)):
|
|
503
|
+
return False, _out_of_time(False, ms), 402
|
|
504
|
+
return out
|
|
505
|
+
|
|
506
|
+
def _bought(self, tool, args):
|
|
507
|
+
if tool.get("builtin") != "buy_time":
|
|
508
|
+
return None
|
|
509
|
+
ms = bought_ms(self.time, args)
|
|
510
|
+
|
|
511
|
+
def stated(hold, value):
|
|
512
|
+
if not isinstance(value, dict) or "msRemaining" not in value:
|
|
513
|
+
return value
|
|
514
|
+
try:
|
|
515
|
+
return {**value, "msRemaining": self.meter.status(hold["channelId"])["msRemaining"]}
|
|
516
|
+
except Exception:
|
|
517
|
+
return value
|
|
518
|
+
|
|
519
|
+
return {"credit": lambda hold: self.meter.credit(hold["channelId"], ms, hold["pendingId"]),
|
|
520
|
+
"undo": lambda hold: self.meter.uncredit(hold["channelId"], ms, hold["pendingId"]),
|
|
521
|
+
"stated": stated}
|
|
522
|
+
|
|
523
|
+
def _serve(self, tool, args, url, payment, grant, refund_url=None, price=None, via="http", credential=""):
|
|
524
|
+
resource = Engine.resource(url, tool["description"])
|
|
525
|
+
if tool.get("meter") == "time" and price is not None:
|
|
526
|
+
bound = self.meter.call_ms(price["micro"])
|
|
527
|
+
run = lambda: self._bounded(bound, tool, args)
|
|
528
|
+
elif caller() is not None:
|
|
529
|
+
run = lambda: self._bounded(None, tool, args)
|
|
530
|
+
else:
|
|
531
|
+
run = lambda: self._run(tool, args)
|
|
532
|
+
base = re.sub(r"/refund$", "", refund_url or "")
|
|
533
|
+
return self.engine.serve(tool["name"], resource, payment, grant, run, refund_url, price, self._listing(base), self._bought(tool, args), bazaar(tool, via), credential)
|
|
534
|
+
|
|
535
|
+
@staticmethod
|
|
536
|
+
def _timed_out(s):
|
|
537
|
+
return s.get("failed") and s["status"] == 402 and isinstance(s["body"], dict) and s["body"].get("error") == "out_of_time"
|
|
538
|
+
|
|
539
|
+
@staticmethod
|
|
540
|
+
def _hung(s):
|
|
541
|
+
return s.get("failed") and s["status"] == 499 and isinstance(s["body"], dict) and s["body"].get("error") == "client_gone"
|
|
542
|
+
|
|
543
|
+
def _free(self, tool, args, client, line=""):
|
|
544
|
+
if tool.get("builtin") == "line":
|
|
545
|
+
out = self._line_tool(args, line)
|
|
546
|
+
return {"ok": not out.get("isError"), "body": out, "status": 500}
|
|
547
|
+
who = client or ""
|
|
548
|
+
take = self._free_limit.take_saved(self._free_file, tool["name"], who) if self._free_file else self._free_limit.take(tool["name"], who)
|
|
549
|
+
if not take["ok"]:
|
|
550
|
+
return {"limited": FreeLimit.refusal(self.engine.free_limit, take["retryAfter"]), "retryAfter": take["retryAfter"]}
|
|
551
|
+
(ok, out, status), _units = metered(lambda: self._run(tool, args))
|
|
552
|
+
return {"ok": ok, "body": out, "status": status}
|
|
553
|
+
|
|
554
|
+
def _on_line(self, tool, args, credential, refund_url=None):
|
|
555
|
+
meter = self.meter
|
|
556
|
+
channel_id = meter.line(credential)
|
|
557
|
+
if not channel_id:
|
|
558
|
+
return {"status": 403, "body": {"error": "line_unknown", "message": 'no open line with that credential. Open one: POST {"op":"open","channelId":"0x…"} to this seller\'s line route, the address its 402 time line names.'}}
|
|
559
|
+
try:
|
|
560
|
+
ch = self.engine.parts()["server"].channels.judged(channel_id)
|
|
561
|
+
except Exception:
|
|
562
|
+
ch = None
|
|
563
|
+
if not ch:
|
|
564
|
+
return {"status": 403, "body": {"error": "line_unknown", "message": "no channel for this line."}}
|
|
565
|
+
if Channels.refunding_wait_ms(ch, meter._now()) > 0:
|
|
566
|
+
secs = Channels.refunding_retry_secs(self.engine.parts()["cfg"], ch, meter._now())
|
|
567
|
+
return {"status": 402, "body": {"error": "refund_settling", "message": f"a refund is settling on this channel, retry in {secs}s.", "retry_after_seconds": secs}}
|
|
568
|
+
if _int(ch.get("withdrawRequestedAt")) > 0:
|
|
569
|
+
return {"status": 402, "body": {"error": "channel_leaving", "message": "this channel is withdrawing. Open a new channel."}}
|
|
570
|
+
heard = caller()
|
|
571
|
+
if heard is not None and heard.gone:
|
|
572
|
+
return {"status": 499, "body": _hung_up(True)}
|
|
573
|
+
ident = secrets.token_hex(8)
|
|
574
|
+
b = meter.begin(channel_id, ident)
|
|
575
|
+
if not b["ok"]:
|
|
576
|
+
ms_remaining = meter.status(channel_id)["msRemaining"]
|
|
577
|
+
if b["code"] == "too_many_calls":
|
|
578
|
+
return {"status": 429, "body": {"error": "too_many_calls", "message": "too many calls are live on this line; wait for one to finish, then retry.", "msRemaining": ms_remaining}}
|
|
579
|
+
at = re.sub(r"/refund$", "", refund_url or "")
|
|
580
|
+
why = f'the meter is off. Switch it on: POST {at}/line {{"op":"on","credential":"<yours>"}}.' if b["code"] == "meter_off" else "this line has no time left. Buy time: buy_time."
|
|
581
|
+
return {"status": 402, "body": {"error": b["code"], "message": why, "msRemaining": ms_remaining}}
|
|
582
|
+
line = {"deadline": b["deadline"], "renewed": False, "spent": False}
|
|
583
|
+
turn = threading.Lock()
|
|
584
|
+
|
|
585
|
+
def renew():
|
|
586
|
+
with turn:
|
|
587
|
+
if not line["spent"] and meter._now() >= line["deadline"]:
|
|
588
|
+
r = meter.renew(channel_id, ident, line["deadline"])
|
|
589
|
+
if r["ok"] and r["deadline"] > line["deadline"]:
|
|
590
|
+
line.update(deadline=r["deadline"], renewed=True)
|
|
591
|
+
else:
|
|
592
|
+
line["spent"] = True
|
|
593
|
+
return line["deadline"]
|
|
594
|
+
|
|
595
|
+
out, _units = metered(lambda: self._within(b["deadline"] - meter._now(), tool, args, lambda: renew() - meter._now()), deadline=b["deadline"], renew=renew, channelId=channel_id)
|
|
596
|
+
ended = meter._now()
|
|
597
|
+
gone = self._gone(out)
|
|
598
|
+
cut = not gone and (out is None or (ended >= line["deadline"] and not out[0]))
|
|
599
|
+
e = meter.end(channel_id, ident) if cut else meter.end(channel_id, ident, b["started"], ended)
|
|
600
|
+
meta = {"channelId": channel_id, "msRemaining": e["msRemaining"], "elapsedMs": ended - b["started"] if line["renewed"] and not cut else e["elapsedMs"]}
|
|
601
|
+
if cut or (not gone and e["over"]):
|
|
602
|
+
return {"status": 402, "body": {**_out_of_time(True), "msRemaining": e["msRemaining"]}}
|
|
603
|
+
if gone:
|
|
604
|
+
return {"status": 499, "body": {**_hung_up(True), "msRemaining": meta["msRemaining"], "elapsedMs": meta["elapsedMs"]}, "meta": meta}
|
|
605
|
+
ok, value, status = out
|
|
606
|
+
if not ok:
|
|
607
|
+
return {"status": status, "failed": value, "meta": meta}
|
|
608
|
+
return {"status": 200, "value": value, "meta": meta}
|
|
609
|
+
|
|
610
|
+
def _line_tool(self, args, header_credential):
|
|
611
|
+
l = self._line_op(args, header_credential)
|
|
612
|
+
return l["body"] if l["status"] == 200 else {"isError": True, "structuredContent": l["body"], "content": _text(l["body"])}
|
|
613
|
+
|
|
614
|
+
def _line_op(self, args, header_credential=""):
|
|
615
|
+
def fail(status, code, why):
|
|
616
|
+
return {"status": status, "body": {"op": "line_failed", "code": code, "why": why}}
|
|
617
|
+
|
|
618
|
+
if not self.time:
|
|
619
|
+
return fail(404, "no_time", "this seller sells no line time")
|
|
620
|
+
if not isinstance(args, dict) or args.get("op") not in LINE_OPS:
|
|
621
|
+
return fail(400, "bad_request", "op is open, prove, on, off, status or close")
|
|
622
|
+
meter = self.meter
|
|
623
|
+
op = args["op"]
|
|
624
|
+
if op in ("open", "prove"):
|
|
625
|
+
cid = args.get("channelId")
|
|
626
|
+
ident = cid.lower() if isinstance(cid, str) and CHANNEL.match(cid) else None
|
|
627
|
+
if not ident:
|
|
628
|
+
return fail(400, "bad_request", 'channelId is missing or is not 0x and 64 hex characters: send "channelId":"0x…" with the op')
|
|
629
|
+
ch = self.engine.channel(ident) if ident else None
|
|
630
|
+
if not ch or not ch.get("channelConfig"):
|
|
631
|
+
return fail(404, "unknown_channel", "no channel with that id here. Pay a call on it first, e.g. buy_time.")
|
|
632
|
+
if _int(ch.get("withdrawRequestedAt")) > 0:
|
|
633
|
+
return fail(409, "channel_leaving", "this channel is withdrawing. Open a new channel.")
|
|
634
|
+
if op == "open":
|
|
635
|
+
c = meter.challenge(ident)
|
|
636
|
+
return {"status": 200, "body": {"op": "challenge", "channelId": ident, "nonce": c["nonce"], "sign": c["message"], "expiresInSeconds": c["expiresInSeconds"]}}
|
|
637
|
+
r = meter.prove(ch, args.get("nonce"), args.get("signature"))
|
|
638
|
+
if not r["ok"]:
|
|
639
|
+
return fail(r["status"], r["code"], r["why"])
|
|
640
|
+
return {"status": 200, "body": {"op": "opened", "credential": r["credential"], "channelId": ident, "msRemaining": r["msRemaining"], "metering": True}}
|
|
641
|
+
credential = args["credential"] if isinstance(args.get("credential"), str) and args["credential"] != "" else header_credential
|
|
642
|
+
if not credential:
|
|
643
|
+
return fail(400, "bad_request", 'credential is missing or is not a string: add "credential":"<yours>" to the body, or send it as the x-line header')
|
|
644
|
+
ident = meter.line(credential)
|
|
645
|
+
if not ident:
|
|
646
|
+
return fail(403, "line_unknown", "no open line with that credential")
|
|
647
|
+
if op == "close":
|
|
648
|
+
meter.close(credential)
|
|
649
|
+
return {"status": 200, "body": {"op": "closed", "channelId": ident}}
|
|
650
|
+
if op == "on":
|
|
651
|
+
holder = "0x" + secrets.token_hex(16)
|
|
652
|
+
channels = self.engine.parts()["server"].channels
|
|
653
|
+
marked = channels.mark(ident, holder, False)
|
|
654
|
+
if marked.get("unavailable"):
|
|
655
|
+
return {"status": 503, "body": {"op": "line_failed", "code": "line_unavailable", "why": "the line could not be read right now. Retry."}}
|
|
656
|
+
if "settling" in marked:
|
|
657
|
+
secs = marked["settling"]
|
|
658
|
+
return {"status": 402, "body": {"op": "line_failed", "code": "refund_settling", "why": f"a refund is settling on this channel, retry in {secs}s.", "retry_after_seconds": secs}}
|
|
659
|
+
if "judged" in marked:
|
|
660
|
+
secs = marked["judged"]
|
|
661
|
+
return {"status": 402, "body": {"op": "line_failed", "code": "refund_pending", "why": f"a refund asked for on this channel is being worked out, and the meter stays off until it is answered. Retry in {secs}s.", "retry_after_seconds": secs}}
|
|
662
|
+
try:
|
|
663
|
+
meter.switch(ident, True)
|
|
664
|
+
finally:
|
|
665
|
+
channels.unmark(ident, holder)
|
|
666
|
+
elif op != "status":
|
|
667
|
+
meter.switch(ident, False)
|
|
668
|
+
return {"status": 200, "body": {"op": op, **meter.status(ident)}}
|
|
669
|
+
|
|
670
|
+
def _probe(self, tool, args, payment, credential):
|
|
671
|
+
return payment == "" and isinstance(args, dict) and not args and not tool.get("builtin") and not self._is_free(tool) and not (tool.get("meter") == "time" and credential)
|
|
672
|
+
|
|
673
|
+
def _probe_terms(self, tool, resource, grant, refund_url, via="http"):
|
|
674
|
+
try:
|
|
675
|
+
price = None if self.mode == "gate" else self._price_for(tool, {})
|
|
676
|
+
except Exception:
|
|
677
|
+
return None
|
|
678
|
+
s = self._serve(tool, {}, resource, "", grant, refund_url, price, via)
|
|
679
|
+
return None if s.get("delivered") or s.get("failed") else s
|
|
680
|
+
|
|
681
|
+
def _price_or_refusal(self, tool, args):
|
|
682
|
+
if self.mode == "gate":
|
|
683
|
+
return None, None
|
|
684
|
+
try:
|
|
685
|
+
return self._price_for(tool, args), None
|
|
686
|
+
except Exception as e:
|
|
687
|
+
return None, {"error": "price_invalid", "tool": tool["name"], "message": str(e)}
|
|
688
|
+
|
|
689
|
+
@staticmethod
|
|
690
|
+
def _http_failure(tool_name, out, status):
|
|
691
|
+
if isinstance(out, dict):
|
|
692
|
+
fields = _failed(out, tool_name)["structuredContent"]
|
|
693
|
+
body = {"error": "tool_failed", "message": _said(out), "tool": tool_name, **fields}
|
|
694
|
+
wait = body.get("retry_after_seconds")
|
|
695
|
+
return _own_status(fields), ({"retry-after": str(wait)} if isinstance(wait, int) and not isinstance(wait, bool) and wait > 0 else {}), body
|
|
696
|
+
return status, {}, {"error": "invalid_arguments" if status == 400 else "tool_failed", "message": out, "tool": tool_name}
|
|
697
|
+
|
|
698
|
+
def _unknown_tool(self, tool):
|
|
699
|
+
return {"error": "unknown_tool", "message": f"no tool named {tool} here; tools lists the ones there are", "tool": tool, "tools": list(self._tools)}
|
|
700
|
+
|
|
701
|
+
def _http(self, tool_name, headers, body, resource=None, refund_url=None, client=""):
|
|
702
|
+
tool = self._tools.get(tool_name)
|
|
703
|
+
if not tool and tool_name == "line":
|
|
704
|
+
l = self._line_op({})
|
|
705
|
+
return l["status"], {}, l["body"]
|
|
706
|
+
if not tool:
|
|
707
|
+
return 404, {}, self._unknown_tool(tool_name)
|
|
708
|
+
try:
|
|
709
|
+
args = _loads(body if body and body.strip() else b"{}")
|
|
710
|
+
except ValueError as e:
|
|
711
|
+
return 400, {}, {"error": "invalid_arguments", "message": f"the body is not JSON: {e}", "tool": tool_name}
|
|
712
|
+
wrong = _validate(tool["inputSchema"], self._asked(tool, args))
|
|
713
|
+
if wrong and self._probe(tool, args, _first(headers, ("payment-signature", "x-payment")) or ("paid" if _mpp_credential(headers) else ""), _first(headers, ("x-line",))):
|
|
714
|
+
s = self._probe_terms(tool, resource, _first(headers, ("x-grant",)), refund_url)
|
|
715
|
+
if s:
|
|
716
|
+
return s["status"], s["headers"], s["body"]
|
|
717
|
+
if wrong:
|
|
718
|
+
return 400, {}, {"error": "invalid_arguments", "message": wrong, "tool": tool_name}
|
|
719
|
+
if tool.get("builtin") == "line":
|
|
720
|
+
l = self._line_op(args, _first(headers, ("x-line",)))
|
|
721
|
+
return l["status"], {}, l["body"]
|
|
722
|
+
if self._is_free(tool):
|
|
723
|
+
f = self._free(tool, args, client, _first(headers, ("x-line",)))
|
|
724
|
+
if "limited" in f:
|
|
725
|
+
return 429, {"retry-after": str(f["retryAfter"])}, f["limited"]
|
|
726
|
+
if not f["ok"]:
|
|
727
|
+
return self._http_failure(tool_name, f["body"], f["status"])
|
|
728
|
+
return 200, {}, f["body"]
|
|
729
|
+
credential = _first(headers, ("x-line",))
|
|
730
|
+
if tool.get("meter") == "time" and credential:
|
|
731
|
+
l = self._on_line(tool, args, credential, refund_url)
|
|
732
|
+
if "failed" in l:
|
|
733
|
+
f = self._http_failure(tool_name, l["failed"], l["status"])
|
|
734
|
+
return f[0], {**f[1], **_reading(l["meta"])}, {**f[2], "msRemaining": l["meta"]["msRemaining"], "elapsedMs": l["meta"]["elapsedMs"]}
|
|
735
|
+
if l["status"] != 200:
|
|
736
|
+
return l["status"], _reading(l["meta"]) if "meta" in l else {}, l["body"]
|
|
737
|
+
return 200, _reading(l["meta"]), l["value"]
|
|
738
|
+
price, invalid = self._price_or_refusal(tool, args)
|
|
739
|
+
if invalid:
|
|
740
|
+
return 500, {}, invalid
|
|
741
|
+
s = self._serve(tool, args, resource, _first(headers, ("payment-signature", "x-payment")), _first(headers, ("x-grant",)), refund_url, price, "http", _mpp_credential(headers))
|
|
742
|
+
if self._timed_out(s):
|
|
743
|
+
return 402, {}, s["body"]
|
|
744
|
+
if self._hung(s):
|
|
745
|
+
return 499, {}, s["body"]
|
|
746
|
+
if s.get("failed"):
|
|
747
|
+
return self._http_failure(tool_name, s["body"], s["status"])
|
|
748
|
+
return s["status"], s["headers"], s["body"]
|
|
749
|
+
|
|
750
|
+
@staticmethod
|
|
751
|
+
def _payment_result(s):
|
|
752
|
+
body = s["body"] if isinstance(s["body"], dict) else {}
|
|
753
|
+
required = _unb64(s["headers"].get("PAYMENT-REQUIRED", "")) if s["headers"].get("PAYMENT-REQUIRED") else None
|
|
754
|
+
if not isinstance(required, dict):
|
|
755
|
+
required = body if "accepts" in body else None
|
|
756
|
+
if not required and s["status"] == 402:
|
|
757
|
+
required = {"x402Version": 2, "accepts": [], **body}
|
|
758
|
+
if required:
|
|
759
|
+
required = {"x402Version": 2, **body, **required}
|
|
760
|
+
return {"isError": True, "structuredContent": required, "content": _text(required), **_mpp_meta(s)}
|
|
761
|
+
if isinstance(s["body"], dict):
|
|
762
|
+
return {"isError": True, "structuredContent": s["body"], "content": _text(s["body"]), **_mpp_meta(s)}
|
|
763
|
+
return {"isError": True, "content": _text(body), **_mpp_meta(s)}
|
|
764
|
+
|
|
765
|
+
@staticmethod
|
|
766
|
+
def _mcp_failure(out, status, name):
|
|
767
|
+
if isinstance(out, dict):
|
|
768
|
+
return _failed(out, name)
|
|
769
|
+
return _failure({"error": "invalid_arguments" if status == 400 else "tool_failed", "tool": name, "message": out})
|
|
770
|
+
|
|
771
|
+
@staticmethod
|
|
772
|
+
def _delivered(out, meta=None):
|
|
773
|
+
extra = {"_meta": meta} if meta else {}
|
|
774
|
+
structured = {"structuredContent": out} if isinstance(out, dict) else {}
|
|
775
|
+
return {"content": _text(out), **structured, **extra}
|
|
776
|
+
|
|
777
|
+
def _mcp_call(self, params, resource=None, grant="", refund_url=None, client="", line=""):
|
|
778
|
+
if not isinstance(params, dict):
|
|
779
|
+
raise _RpcError(-32602, "params must be an object")
|
|
780
|
+
name = params.get("name")
|
|
781
|
+
if not isinstance(name, str):
|
|
782
|
+
raise _RpcError(-32602, "params.name must be a string")
|
|
783
|
+
meta = params.get("_meta", {})
|
|
784
|
+
if meta is None:
|
|
785
|
+
meta = {}
|
|
786
|
+
if not isinstance(meta, dict):
|
|
787
|
+
raise _RpcError(-32602, "params._meta must be an object")
|
|
788
|
+
args = params.get("arguments", {})
|
|
789
|
+
if args is None:
|
|
790
|
+
args = {}
|
|
791
|
+
if not isinstance(args, dict):
|
|
792
|
+
raise _RpcError(-32602, "params.arguments must be an object")
|
|
793
|
+
payment = meta.get(META_PAYMENT)
|
|
794
|
+
paying = payment is not None and payment is not False and payment != 0 and payment != ""
|
|
795
|
+
tool = self._tools.get(name)
|
|
796
|
+
if not tool:
|
|
797
|
+
return _failure(self._unknown_tool(name))
|
|
798
|
+
wrong = _validate(tool["inputSchema"], self._asked(tool, args))
|
|
799
|
+
credential = meta.get(META_LINE).strip() if isinstance(meta.get(META_LINE), str) and meta[META_LINE].strip() else (line or "").strip()
|
|
800
|
+
if wrong and self._probe(tool, args, "paid" if paying or meta.get(McpPayment.CREDENTIAL) else "", credential):
|
|
801
|
+
s = self._probe_terms(tool, resource, grant, refund_url, "mcp")
|
|
802
|
+
if s:
|
|
803
|
+
return self._payment_result(s)
|
|
804
|
+
if wrong:
|
|
805
|
+
return _failure({"error": "invalid_arguments", "tool": name, "message": wrong})
|
|
806
|
+
if self._is_free(tool):
|
|
807
|
+
f = self._free(tool, args, client, credential)
|
|
808
|
+
if "limited" in f:
|
|
809
|
+
return _failure(f["limited"])
|
|
810
|
+
if not f["ok"]:
|
|
811
|
+
return self._mcp_failure(f["body"], f["status"], name)
|
|
812
|
+
return self._delivered(f["body"])
|
|
813
|
+
if tool.get("meter") == "time" and credential:
|
|
814
|
+
l = self._on_line(tool, args, credential, refund_url)
|
|
815
|
+
if "failed" in l:
|
|
816
|
+
return _metered(self._mcp_failure(l["failed"], l["status"], name), l["meta"])
|
|
817
|
+
if l["status"] != 200:
|
|
818
|
+
return _metered(_failure(l["body"]), l.get("meta"))
|
|
819
|
+
return self._delivered(l["value"], {META_METER: l["meta"]})
|
|
820
|
+
price, invalid = self._price_or_refusal(tool, args)
|
|
821
|
+
if invalid:
|
|
822
|
+
return _failure(invalid)
|
|
823
|
+
s = self._serve(tool, args, resource, _b64(payment) if paying else "", grant, refund_url, price, "mcp", "" if paying else McpPayment.header(meta))
|
|
824
|
+
if self._timed_out(s) or self._hung(s):
|
|
825
|
+
return _failure(s["body"])
|
|
826
|
+
if s.get("failed"):
|
|
827
|
+
return self._mcp_failure(s["body"], s["status"], name)
|
|
828
|
+
if not s["delivered"]:
|
|
829
|
+
return self._payment_result(s)
|
|
830
|
+
return self._delivered(s["body"], _settled_meta(s["headers"]))
|
|
831
|
+
|
|
832
|
+
def _takes_mpp(self):
|
|
833
|
+
if self.mode == "gate":
|
|
834
|
+
return False
|
|
835
|
+
try:
|
|
836
|
+
return self.engine.parts()["server"].mpp_asset() is not None
|
|
837
|
+
except Exception:
|
|
838
|
+
return False
|
|
839
|
+
|
|
840
|
+
def _callers(self, line, session, meta):
|
|
841
|
+
named = meta.get(META_LINE) if isinstance(meta, dict) else None
|
|
842
|
+
credential = named.strip() if isinstance(named, str) and named.strip() else (line or "").strip()
|
|
843
|
+
ident = session.strip() if isinstance(session, str) else ""
|
|
844
|
+
by = set()
|
|
845
|
+
if credential:
|
|
846
|
+
by.add(f"line {credential}")
|
|
847
|
+
if McpPayment.in_session(ident) or MCP_SESSION.match(ident):
|
|
848
|
+
by.add(f"session {ident}")
|
|
849
|
+
return by
|
|
850
|
+
|
|
851
|
+
def _called(self, ident, line, session, meta):
|
|
852
|
+
key = _call_key(ident)
|
|
853
|
+
call = (Hangup(caller()), self._callers(line, session, meta))
|
|
854
|
+
if key is not None:
|
|
855
|
+
with self._calls:
|
|
856
|
+
self._calling.setdefault(key, []).append(call)
|
|
857
|
+
|
|
858
|
+
def land():
|
|
859
|
+
call[0].leave()
|
|
860
|
+
with self._calls:
|
|
861
|
+
live = [c for c in self._calling.get(key, ()) if c is not call]
|
|
862
|
+
if live:
|
|
863
|
+
self._calling[key] = live
|
|
864
|
+
else:
|
|
865
|
+
self._calling.pop(key, None)
|
|
866
|
+
|
|
867
|
+
return call[0], land
|
|
868
|
+
|
|
869
|
+
def _canceled(self, msg, line, session):
|
|
870
|
+
params = msg.get("params")
|
|
871
|
+
if msg.get("method") != "notifications/cancelled" or not isinstance(params, dict):
|
|
872
|
+
return
|
|
873
|
+
key = _call_key(params.get("requestId"))
|
|
874
|
+
if key is None:
|
|
875
|
+
return
|
|
876
|
+
by = self._callers(line, session, params.get("_meta"))
|
|
877
|
+
with self._calls:
|
|
878
|
+
found = [c for c in self._calling.get(key, ()) if c[1] & by]
|
|
879
|
+
for c in found:
|
|
880
|
+
c[0].hang_up()
|
|
881
|
+
|
|
882
|
+
def _mcp_one(self, msg, resource=None, grant="", refund_url=None, client="", line="", payment=False, session=""):
|
|
883
|
+
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
|
|
884
|
+
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request"}}
|
|
885
|
+
if "id" not in msg:
|
|
886
|
+
self._canceled(msg, line, session)
|
|
887
|
+
return None
|
|
888
|
+
ident = msg["id"]
|
|
889
|
+
try:
|
|
890
|
+
method = msg["method"]
|
|
891
|
+
params = msg.get("params")
|
|
892
|
+
if params is not None and not isinstance(params, (dict, list)):
|
|
893
|
+
raise _RpcError(-32602, "params must be an object")
|
|
894
|
+
if method == "initialize":
|
|
895
|
+
asked = params.get("protocolVersion") if isinstance(params, dict) else None
|
|
896
|
+
declared = isinstance(params, dict) and McpPayment.declares(params.get("capabilities")) and self._takes_mpp() and self._sells_by_charge()
|
|
897
|
+
result = {"protocolVersion": asked if asked in MCP_VERSIONS else MCP_VERSIONS[0],
|
|
898
|
+
"capabilities": {"tools": {}, **({"experimental": {"payment": McpPayment.capability()}} if declared else {})},
|
|
899
|
+
"serverInfo": {"name": self.server_name, "version": self.version}}
|
|
900
|
+
elif method == "ping":
|
|
901
|
+
result = {}
|
|
902
|
+
elif method == "tools/list":
|
|
903
|
+
result = {"tools": self.tools()}
|
|
904
|
+
elif method == "tools/call":
|
|
905
|
+
asked = McpPayment.params(msg)
|
|
906
|
+
heard, land = self._called(ident, line, session, asked.get("_meta") if isinstance(asked, dict) else None)
|
|
907
|
+
token = hearing(heard)
|
|
908
|
+
try:
|
|
909
|
+
result = self._mcp_call(asked, resource, grant, refund_url, client, line)
|
|
910
|
+
finally:
|
|
911
|
+
unhearing(token)
|
|
912
|
+
land()
|
|
913
|
+
unpaid = McpPayment.error(result.get("_meta", {}).get(McpPayment.REQUIRED), result.get("structuredContent")) if payment else None
|
|
914
|
+
if unpaid is not None:
|
|
915
|
+
return {"jsonrpc": "2.0", "id": ident, "error": unpaid}
|
|
916
|
+
else:
|
|
917
|
+
raise _RpcError(-32601, f"method not found: {method}")
|
|
918
|
+
return {"jsonrpc": "2.0", "id": ident, "result": result}
|
|
919
|
+
except _RpcError as e:
|
|
920
|
+
return {"jsonrpc": "2.0", "id": ident, "error": {"code": e.code, "message": str(e)}}
|
|
921
|
+
except Exception as e:
|
|
922
|
+
return {"jsonrpc": "2.0", "id": ident, "error": {"code": -32603, "message": f"internal error: {e}"}}
|
|
923
|
+
|
|
924
|
+
def _mcp(self, body, resource=None, grant="", refund_url=None, client="", line="", session=""):
|
|
925
|
+
try:
|
|
926
|
+
data = _loads(body or b"")
|
|
927
|
+
except ValueError:
|
|
928
|
+
return 400, {}, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
|
|
929
|
+
batch = isinstance(data, list)
|
|
930
|
+
if batch and not data:
|
|
931
|
+
return 400, {}, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request: empty batch"}}
|
|
932
|
+
items = data if batch else [data]
|
|
933
|
+
payment = McpPayment.in_session(session) and self._takes_mpp()
|
|
934
|
+
one = lambda m: self._mcp_one(m, resource, grant, refund_url, client, line, payment, session)
|
|
935
|
+
opened = {}
|
|
936
|
+
if not batch and isinstance(data, dict) and data.get("method") == "initialize" and "id" in data:
|
|
937
|
+
declared = isinstance(data.get("params"), dict) and McpPayment.declares(data["params"].get("capabilities")) and self._takes_mpp()
|
|
938
|
+
issued = McpPayment.session() if declared else "mcp." + base64.urlsafe_b64encode(secrets.token_bytes(12)).decode()
|
|
939
|
+
opened = {"mcp-session-id": issued, "access-control-expose-headers": CORS["access-control-expose-headers"] + ", Mcp-Session-Id"}
|
|
940
|
+
if len(items) > 1:
|
|
941
|
+
carried = [contextvars.copy_context() for _ in items]
|
|
942
|
+
with ThreadPoolExecutor(max_workers=len(items)) as pool:
|
|
943
|
+
found = list(pool.map(lambda pair: pair[0].run(one, pair[1]), zip(carried, items)))
|
|
944
|
+
else:
|
|
945
|
+
found = [one(m) for m in items]
|
|
946
|
+
answers = [a for a in found if a is not None]
|
|
947
|
+
if not answers:
|
|
948
|
+
return 202, {}, None
|
|
949
|
+
return 200, opened, answers if batch else answers[0]
|
|
950
|
+
|
|
951
|
+
def _responses(self, tool):
|
|
952
|
+
if self._is_free(tool):
|
|
953
|
+
out = {"200": {"description": "the result"}, "400": {"description": "invalid arguments"}, "500": {"description": "the tool failed"}}
|
|
954
|
+
if self.engine.free_limit and not tool.get("builtin"):
|
|
955
|
+
out["429"] = {"description": f"over {self.engine.free_limit} free calls an hour per address"}
|
|
956
|
+
out["413"] = {"description": "the body is over the size this seller reads; nothing is charged"}
|
|
957
|
+
if tool.get("name") == "line":
|
|
958
|
+
out.update({"400": {"description": "invalid arguments (error invalid_arguments), or a step refused (op line_failed, with code and why)"}, "402": {"description": "a refund settling on the channel answers refund_settling; \"on\" while a refund asked for on the channel is being worked out answers refund_pending"}, "403": {"description": "no line with that credential, or the proof is not the payer's"},
|
|
959
|
+
"404": {"description": "no channel with that id here, or this seller sells no line time"}, "409": {"description": "the channel is leaving; code and why say so"},
|
|
960
|
+
"503": {"description": "the line could not be read right now (code line_unavailable; nothing changed, send it again)"}})
|
|
961
|
+
return out
|
|
962
|
+
timed = tool.get("meter") == "time"
|
|
963
|
+
unavailable = ("the check failed (error payment_unavailable), this gate has no checks left this month (error gate_credit_exhausted)" if self.mode == "gate"
|
|
964
|
+
else "the payment check or settlement failed (error payment_unavailable)")
|
|
965
|
+
out = {"200": {"description": "the result"},
|
|
966
|
+
"400": {"description": "invalid arguments; nothing is charged" if self.mode == "gate" else "invalid arguments (error invalid_arguments), or a payment that does not decode (error bad_payment_header) or is neither a voucher nor a deposit (error bad_payment_type); nothing is charged"},
|
|
967
|
+
"402": {"description": "a zero-value x402 signature is required; nothing is paid" if self.mode == "gate" else
|
|
968
|
+
("x402 payment required" if tool.get("builtin") == "buy_time" or self._price_tag(tool).get("per") == "unit" else "payment required: x402, or MPP when the 402 carries a Payment challenge")
|
|
969
|
+
+ ("; on a line, the meter is off or the line is out of time" if timed else "") + "; a refund settling on the channel answers refund_settling"},
|
|
970
|
+
"403": {"description": "key not admitted" + ("; on a line, no line with that credential" if timed else "")},
|
|
971
|
+
"413": {"description": "the body is over the size this seller reads; nothing is charged"},
|
|
972
|
+
"500": {"description": "the tool failed; nothing is charged per call" + (", and on a line the call has used the time it ran" if timed else "")},
|
|
973
|
+
"503": {"description": unavailable + ", or this seller's setup is incomplete (error not_ready); nothing is charged. Retry later"}}
|
|
974
|
+
if timed:
|
|
975
|
+
out["429"] = {"description": "too many calls are live on the line (error too_many_calls)"}
|
|
976
|
+
return out
|
|
977
|
+
|
|
978
|
+
def _sells_by_charge(self):
|
|
979
|
+
if self._mpp_offers():
|
|
980
|
+
return True
|
|
981
|
+
return callable(self.engine.prices) and any(not self._is_free(t) and not t.get("builtin") and not self._own_price(t) for t in self._tools.values())
|
|
982
|
+
|
|
983
|
+
def _mpp_offers(self):
|
|
984
|
+
if self.mode == "gate":
|
|
985
|
+
return {}
|
|
986
|
+
try:
|
|
987
|
+
server = self.engine.parts()["server"]
|
|
988
|
+
except Exception:
|
|
989
|
+
return {}
|
|
990
|
+
offers = {}
|
|
991
|
+
for t in self._tools.values():
|
|
992
|
+
if self._is_free(t) or (not self._own_price(t) and callable(self.engine.prices)):
|
|
993
|
+
continue
|
|
994
|
+
try:
|
|
995
|
+
offer = server.mpp_offer(self._price_for(t, {}), t.get("description"))
|
|
996
|
+
except Exception:
|
|
997
|
+
offer = None
|
|
998
|
+
if offer is not None:
|
|
999
|
+
offers[t["name"]] = offer
|
|
1000
|
+
return offers
|
|
1001
|
+
|
|
1002
|
+
def discovery(self, origin, mount=""):
|
|
1003
|
+
base = (self._public(origin.rstrip("/") + mount) or "").rstrip("/")
|
|
1004
|
+
return self.openapi(base, mount)
|
|
1005
|
+
|
|
1006
|
+
def openapi(self, base_url, mount=None):
|
|
1007
|
+
paths = {f"/v1/{t['name']}": {"post": {"operationId": t["name"], "summary": t["description"], "x-price": self._price_tag(t),
|
|
1008
|
+
"requestBody": {"required": True, "content": {"application/json": {"schema": t["inputSchema"]}}},
|
|
1009
|
+
"responses": self._responses(t)}}
|
|
1010
|
+
for t in self._tools.values()}
|
|
1011
|
+
info = {"title": self.server_name, "version": self.version,
|
|
1012
|
+
"description": f"{HOW[self.mode]}. MCP: {base_url}/mcp"}
|
|
1013
|
+
contact = self.engine.contact
|
|
1014
|
+
if contact:
|
|
1015
|
+
info["contact"] = {"email": contact[7:]} if contact.lower().startswith("mailto:") else {"url": contact}
|
|
1016
|
+
doc = {"openapi": "3.0.3", "info": info, "servers": [{"url": base_url}], "paths": paths}
|
|
1017
|
+
if mount is None:
|
|
1018
|
+
return discovery(doc, self._mpp_offers(), self.site)
|
|
1019
|
+
return discovery(doc, self._mpp_offers(), self.site, base_url[:len(base_url) - len(mount)], mount)
|
|
1020
|
+
|
|
1021
|
+
def refund(self, body):
|
|
1022
|
+
try:
|
|
1023
|
+
args = json.loads(body or b"{}")
|
|
1024
|
+
except ValueError:
|
|
1025
|
+
return 400, {}, {"op": "refund_failed", "code": "invalid_json", "why": "the body is not JSON"}
|
|
1026
|
+
if not isinstance(args, dict):
|
|
1027
|
+
return 400, {}, {"op": "refund_failed", "code": "invalid_request", "why": "expected { channelId, issued, signature }, optional selfSend or gasPayment"}
|
|
1028
|
+
try:
|
|
1029
|
+
r = self.engine.refund(args)
|
|
1030
|
+
except Exception as e:
|
|
1031
|
+
return 503, {}, {"op": "refund_failed", "code": "refund_unavailable", "why": f"the refund could not be handled right now: {e}", "retry_after_seconds": 5}
|
|
1032
|
+
return r["status"], {}, r["body"]
|
|
1033
|
+
|
|
1034
|
+
@staticmethod
|
|
1035
|
+
def ours(path):
|
|
1036
|
+
return path in ("/mcp", "/openapi.json", "/refund", "/line") or path.startswith("/v1/")
|
|
1037
|
+
|
|
1038
|
+
def handle(self, method, path, headers, body, base_url="", client="", hangup=None):
|
|
1039
|
+
token = None if hangup is None else hearing(hangup)
|
|
1040
|
+
try:
|
|
1041
|
+
out = self._handle(method, path, {k.lower(): v for k, v in headers.items()}, body, base_url, client or "")
|
|
1042
|
+
finally:
|
|
1043
|
+
if token is not None:
|
|
1044
|
+
unhearing(token)
|
|
1045
|
+
if out is None:
|
|
1046
|
+
return None
|
|
1047
|
+
status, extra, payload = out
|
|
1048
|
+
return status, {**CORS, **extra, **({"content-type": PROBLEM_JSON} if _problem(payload) else {})}, payload
|
|
1049
|
+
|
|
1050
|
+
def _handle(self, method, path, headers, body, base_url, client=""):
|
|
1051
|
+
if method == "OPTIONS" and self.ours(path):
|
|
1052
|
+
return 204, dict(PREFLIGHT), None
|
|
1053
|
+
refund_at = self._public(base_url + "/refund")
|
|
1054
|
+
mcp = self._public(base_url + "/mcp")
|
|
1055
|
+
if path == "/mcp":
|
|
1056
|
+
if method != "POST":
|
|
1057
|
+
return 405, {"allow": "POST, OPTIONS"}, None
|
|
1058
|
+
return self._mcp(body, mcp, _first(headers, ("x-grant",)), refund_at, client, _first(headers, ("x-line",)), _first(headers, ("mcp-session-id",)))
|
|
1059
|
+
if path == "/openapi.json" and method == "GET":
|
|
1060
|
+
return 200, {"cache-control": CACHE_CONTROL}, self.openapi((self._public(base_url) or base_url).rstrip("/"))
|
|
1061
|
+
if path == "/refund" and method == "POST":
|
|
1062
|
+
return self.refund(body)
|
|
1063
|
+
if path == "/line" and method == "POST":
|
|
1064
|
+
if not self.time:
|
|
1065
|
+
l = self._line_op({})
|
|
1066
|
+
return l["status"], {}, l["body"]
|
|
1067
|
+
try:
|
|
1068
|
+
args = _loads(body or b"")
|
|
1069
|
+
except ValueError:
|
|
1070
|
+
return 400, {}, {"op": "line_failed", "code": "bad_request", "why": "the body is not JSON"}
|
|
1071
|
+
l = self._line_op(args, _first(headers, ("x-line",)))
|
|
1072
|
+
return l["status"], {}, l["body"]
|
|
1073
|
+
if path.startswith("/v1/") and method == "POST":
|
|
1074
|
+
return self._http(path[4:], headers, body, self._public(base_url + path), refund_at, client)
|
|
1075
|
+
return None
|
|
1076
|
+
|
|
1077
|
+
def asgi(self, fallback=None):
|
|
1078
|
+
async def app(scope, receive, send):
|
|
1079
|
+
if scope["type"] != "http":
|
|
1080
|
+
if fallback:
|
|
1081
|
+
return await fallback(scope, receive, send)
|
|
1082
|
+
return
|
|
1083
|
+
path = scope["path"][len(scope.get("root_path", "")):] or "/"
|
|
1084
|
+
if not self.ours(path) and fallback:
|
|
1085
|
+
return await fallback(scope, receive, send)
|
|
1086
|
+
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
|
|
1087
|
+
try:
|
|
1088
|
+
declared = int(headers.get("content-length") or 0)
|
|
1089
|
+
except ValueError:
|
|
1090
|
+
declared = 0
|
|
1091
|
+
chunks, size, big = [], 0, declared > MAX_BODY
|
|
1092
|
+
while not big:
|
|
1093
|
+
event = await receive()
|
|
1094
|
+
if event.get("type") == "http.disconnect":
|
|
1095
|
+
return
|
|
1096
|
+
chunk = event.get("body", b"")
|
|
1097
|
+
size += len(chunk)
|
|
1098
|
+
if size > MAX_BODY:
|
|
1099
|
+
big = True
|
|
1100
|
+
break
|
|
1101
|
+
chunks.append(chunk)
|
|
1102
|
+
if not event.get("more_body"):
|
|
1103
|
+
break
|
|
1104
|
+
body = b"".join(chunks)
|
|
1105
|
+
gone = Hangup()
|
|
1106
|
+
if big:
|
|
1107
|
+
out = (413, dict(CORS), {"error": "body_too_large", "message": f"the body is over {MAX_BODY} bytes", "limit": MAX_BODY})
|
|
1108
|
+
else:
|
|
1109
|
+
host = headers.get("host", "localhost")
|
|
1110
|
+
base_url = f"{scope.get('scheme', 'http')}://{host}{scope.get('root_path', '')}"
|
|
1111
|
+
peer = scope.get("client")
|
|
1112
|
+
client = str(peer[0]) if isinstance(peer, (list, tuple)) and peer and peer[0] is not None else ""
|
|
1113
|
+
|
|
1114
|
+
async def listen():
|
|
1115
|
+
try:
|
|
1116
|
+
while (await receive()).get("type") != "http.disconnect":
|
|
1117
|
+
pass
|
|
1118
|
+
except Exception:
|
|
1119
|
+
return
|
|
1120
|
+
gone.hang_up()
|
|
1121
|
+
|
|
1122
|
+
listener = asyncio.ensure_future(listen())
|
|
1123
|
+
try:
|
|
1124
|
+
out = await asyncio.to_thread(self.handle, scope["method"], path, headers, body, base_url, client, gone)
|
|
1125
|
+
except BaseException:
|
|
1126
|
+
gone.hang_up()
|
|
1127
|
+
raise
|
|
1128
|
+
finally:
|
|
1129
|
+
listener.cancel()
|
|
1130
|
+
if out is None:
|
|
1131
|
+
if fallback:
|
|
1132
|
+
replayed = [{"type": "http.request", "body": body, "more_body": False}]
|
|
1133
|
+
|
|
1134
|
+
async def again():
|
|
1135
|
+
if replayed:
|
|
1136
|
+
return replayed.pop()
|
|
1137
|
+
return {"type": "http.disconnect"} if gone.gone else await receive()
|
|
1138
|
+
return await fallback(scope, again, send)
|
|
1139
|
+
out = (404, {}, {"error": "not_found", "message": "nothing is served at this path"})
|
|
1140
|
+
status, extra, payload = out
|
|
1141
|
+
raw = b"" if payload is None else stringify(payload).encode()
|
|
1142
|
+
hdrs = [(b"content-type", str(extra.get("content-type", "application/json")).encode())] + [(k.lower().encode(), str(v).encode()) for k, v in extra.items() if k.lower() != "content-type"]
|
|
1143
|
+
try:
|
|
1144
|
+
await send({"type": "http.response.start", "status": status, "headers": hdrs})
|
|
1145
|
+
await send({"type": "http.response.body", "body": raw})
|
|
1146
|
+
except Exception:
|
|
1147
|
+
if not gone.gone:
|
|
1148
|
+
raise
|
|
1149
|
+
return app
|
|
1150
|
+
|
|
1151
|
+
def wsgi(self, fallback=None):
|
|
1152
|
+
def app(environ, start_response):
|
|
1153
|
+
path = environ.get("PATH_INFO") or "/"
|
|
1154
|
+
try:
|
|
1155
|
+
path = path.encode("latin-1").decode("utf-8")
|
|
1156
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
1157
|
+
pass
|
|
1158
|
+
if not self.ours(path) and fallback:
|
|
1159
|
+
return fallback(environ, start_response)
|
|
1160
|
+
try:
|
|
1161
|
+
length = max(int(environ.get("CONTENT_LENGTH") or 0), 0)
|
|
1162
|
+
except ValueError:
|
|
1163
|
+
length = 0
|
|
1164
|
+
if length > MAX_BODY:
|
|
1165
|
+
out = (413, dict(CORS), {"error": "body_too_large", "message": f"the body is over {MAX_BODY} bytes", "limit": MAX_BODY})
|
|
1166
|
+
body = b""
|
|
1167
|
+
else:
|
|
1168
|
+
body = environ["wsgi.input"].read(length) if length else b""
|
|
1169
|
+
headers = {k[5:].replace("_", "-").lower(): v for k, v in environ.items() if k.startswith("HTTP_")}
|
|
1170
|
+
if environ.get("CONTENT_TYPE"):
|
|
1171
|
+
headers["content-type"] = environ["CONTENT_TYPE"]
|
|
1172
|
+
base_url = f"{environ.get('wsgi.url_scheme', 'http')}://{environ.get('HTTP_HOST', 'localhost')}{environ.get('SCRIPT_NAME', '')}"
|
|
1173
|
+
out = self.handle(environ["REQUEST_METHOD"], path, headers, body, base_url, environ.get("REMOTE_ADDR") or "")
|
|
1174
|
+
if out is None:
|
|
1175
|
+
if fallback:
|
|
1176
|
+
environ["wsgi.input"] = io.BytesIO(body)
|
|
1177
|
+
environ["CONTENT_LENGTH"] = str(len(body))
|
|
1178
|
+
return fallback(environ, start_response)
|
|
1179
|
+
out = (404, {}, {"error": "not_found", "message": "nothing is served at this path"})
|
|
1180
|
+
status, extra, payload = out
|
|
1181
|
+
raw = b"" if payload is None else stringify(payload).encode()
|
|
1182
|
+
start_response(f"{status} {_reason(status)}", [("content-type", str(extra.get("content-type", "application/json")))] + [(k, str(v)) for k, v in extra.items() if k.lower() != "content-type"])
|
|
1183
|
+
return [raw]
|
|
1184
|
+
return app
|
|
1185
|
+
|
|
1186
|
+
def paid(self, fn):
|
|
1187
|
+
priced = {"name": fn.__name__}
|
|
1188
|
+
if self.mode != "gate" and not callable(self.engine.prices):
|
|
1189
|
+
self._price_for(priced)
|
|
1190
|
+
|
|
1191
|
+
@functools.wraps(fn)
|
|
1192
|
+
def wrapper(*args, **kwargs):
|
|
1193
|
+
from flask import request, make_response
|
|
1194
|
+
box = {}
|
|
1195
|
+
price, invalid = self._price_or_refusal(priced, dict(kwargs))
|
|
1196
|
+
if invalid:
|
|
1197
|
+
return self._flask_json(make_response, invalid, 500)
|
|
1198
|
+
|
|
1199
|
+
def run():
|
|
1200
|
+
box["resp"] = make_response(fn(*args, **kwargs))
|
|
1201
|
+
return (box["resp"].status_code < 400), box["resp"], box["resp"].status_code
|
|
1202
|
+
|
|
1203
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
1204
|
+
resource = Engine.resource(self._public(request.url), (fn.__doc__ or "").strip())
|
|
1205
|
+
s = self.engine.serve(fn.__name__, resource, _first(headers, ("payment-signature", "x-payment")), _first(headers, ("x-grant",)), run, None, price, self._listing(), None, None, _mpp_credential(headers))
|
|
1206
|
+
if s.get("failed"):
|
|
1207
|
+
return s["body"]
|
|
1208
|
+
if not s["delivered"]:
|
|
1209
|
+
return self._flask(self._flask_json(make_response, s["body"], s["status"]), s["headers"])
|
|
1210
|
+
return self._flask(s["body"], s["headers"])
|
|
1211
|
+
return wrapper
|
|
1212
|
+
|
|
1213
|
+
def refund_view(self):
|
|
1214
|
+
def refund():
|
|
1215
|
+
from flask import request, make_response
|
|
1216
|
+
status, _, body = self.refund(request.get_data())
|
|
1217
|
+
return self._flask_json(make_response, body, status)
|
|
1218
|
+
return refund
|
|
1219
|
+
|
|
1220
|
+
@staticmethod
|
|
1221
|
+
def _flask_json(make_response, body, status):
|
|
1222
|
+
resp = make_response(stringify(body), status)
|
|
1223
|
+
resp.headers["Content-Type"] = PROBLEM_JSON if _problem(body) else "application/json"
|
|
1224
|
+
return resp
|
|
1225
|
+
|
|
1226
|
+
@staticmethod
|
|
1227
|
+
def _flask(resp, headers):
|
|
1228
|
+
for k, v in headers.items():
|
|
1229
|
+
resp.headers[k] = v
|
|
1230
|
+
return resp
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
Pass = Bridge
|