Files
Mujoco_WASM/decision_server/web_server.py
T
chenlin f3a8a38acd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Waiting to run
web-platform-ci / TypeScript, lint, unit, build (push) Waiting to run
web-platform-ci / Playwright E2E (push) Waiting to run
lekiwi-compatibility / cpu-compatibility (push) Waiting to run
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Waiting to run
web-platform-ci / TypeScript, lint, unit, build (pull_request) Waiting to run
web-platform-ci / Playwright E2E (pull_request) Waiting to run
lekiwi-compatibility / cpu-compatibility (pull_request) Waiting to run
feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
2026-09-24 09:57:41 +08:00

252 lines
10 KiB
Python

"""Same-origin public BYOK gateway. Separate from the local Bearer application."""
import asyncio
import hmac
import ipaddress
import time
from pathlib import Path
import aiohttp
from aiohttp import web
from . import server
from .model_catalog import ModelCatalog
from .protocol import DecisionError, fields
from .web_config import COOKIE, configure, public_config, website_origin
from .web_sessions import Sessions, Visitor
VISITOR = web.RequestKey("website_visitor", Visitor)
CLIENT_IP = web.RequestKey("website_ip", str)
MANAGER = web.AppKey("website_sessions", Sessions)
CATALOG = web.AppKey("website_catalog", ModelCatalog)
PREFIX = server.PREFIX
def view(visitor):
s = visitor.service
return {
"version": "lekiwi-agent-v1",
"configVersion": s.epoch,
"configuration": public_config(s.connections.values),
"ready": bool(s.connections.values),
"active": len(s.active),
"keyStorage": "memory-only",
}
def create_website_app(
origin, directory=None, *, development=False, limits=None, trusted_proxies=()
):
host = website_origin(origin, development=development)
manager = Sessions(directory or Path("/tmp/cadworld-sessions"), origin, limits)
catalog = ModelCatalog()
cookie = "cadworld-dev-session" if development else COOKIE
trusted = set(trusted_proxies)
def client_ip(request):
remote = request.remote or "unknown"
if remote in trusted:
try:
return str(ipaddress.ip_address(request.headers.get("X-Real-IP", "")))
except ValueError:
raise DecisionError("invalid_proxy_ip", 403) from None
return remote
@web.middleware
async def boundary(request, handler):
visitor = None
try:
if request.headers.get("Host") != host:
raise DecisionError("host_forbidden", 403)
supplied_origin = request.headers.get("Origin")
if supplied_origin and supplied_origin != origin:
raise DecisionError("origin_forbidden", 403)
if request.headers.get("Sec-Fetch-Site") in ("cross-site", "same-site"):
raise DecisionError("origin_forbidden", 403)
if request.query_string:
raise DecisionError("query_forbidden", 400)
if request.path == "/healthz" and request.method == "GET":
response = web.json_response({"ok": True})
else:
await manager.expire()
write = request.method not in ("GET", "HEAD")
if write and supplied_origin != origin:
raise DecisionError("origin_required", 403)
bootstrap = request.path == PREFIX + "/session" and request.method == "POST"
visitor = manager.values.get(request.cookies.get(cookie, ""))
if bootstrap:
fields(await server.body(request), [])
# Restoring a cookie is read-only: don't extend credential lifetime.
if visitor is None:
visitor = manager.create(client_ip(request))
response = web.json_response({**view(visitor), "csrfToken": visitor.csrf})
response.set_cookie(
cookie,
visitor.ident,
secure=not development,
httponly=True,
samesite="Strict",
path="/",
)
else:
if visitor is None or visitor.closed:
raise DecisionError("session_expired", 401)
if write:
if not hmac.compare_digest(
request.headers.get("X-CSRF-Token", ""), visitor.csrf
):
raise DecisionError("csrf_required", 403)
if request.path not in (PREFIX + "/session", PREFIX + "/cancel") and (
request.headers.get("X-Config-Version") != str(visitor.service.epoch)
):
raise DecisionError("configuration_changed", 409)
visitor.touched = time.monotonic()
request[VISITOR] = visitor
request[server.WEB_SERVICE] = visitor.service
request[CLIENT_IP] = client_ip(request)
response = await handler(request)
except DecisionError as exc:
response = web.json_response({"error": exc.code}, status=exc.status)
except web.HTTPException as exc:
response = web.json_response({"error": "http_request_rejected"}, status=exc.status)
except Exception:
response = web.json_response({"error": "internal_error"}, status=500)
response.headers.update({"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"})
if visitor and not visitor.closed:
response.headers["X-Config-Version"] = str(visitor.service.epoch)
return response
app = web.Application(middlewares=[boundary], client_max_size=65536)
app[MANAGER], app[CATALOG] = manager, catalog
async def health(_):
return web.json_response({"ok": True})
async def session(_):
# Bootstrap handled in middleware, deliberately independent of model/CLI availability.
raise DecisionError("invalid_session_method", 405)
async def destroy(request):
await manager.destroy(request[VISITOR])
response = web.json_response({"cleared": True})
response.del_cookie(
cookie, path="/", secure=not development, httponly=True, samesite="Strict"
)
return response
async def status(request):
return web.json_response(view(request[VISITOR]))
async def models(_):
await catalog.refresh(manager.http)
return web.json_response(catalog.public())
async def configuration(request):
visitor = request[VISITOR]
s = visitor.service
epoch = s.epoch
data = await server.body(request)
fields(data, ["llm", "jev"])
fields(data["llm"], ["provider", "model"], ["apiKey"])
codex_models = ()
if data["llm"]["provider"] == "openrouter":
await catalog.refresh(manager.http)
elif data["llm"]["provider"] == "codex":
if not visitor.codex_reserved or not (await s.codex.status())["loggedIn"]:
raise DecisionError("codex_chatgpt_login_required", 409)
codex_models = [m["id"] for m in (await s.codex.models())["models"]]
if visitor.closed or s.epoch != epoch:
raise DecisionError("configuration_changed", 409)
values = configure(s.connections.values, data, catalog.models, codex_models)
s.invalidate()
s.connections.values = values
return web.json_response(view(visitor))
async def inference(request):
visitor = request[VISITOR]
operation = request.match_info["operation"]
llm = visitor.service.connections.values.get("llm")
is_llm = operation == "plan" or (
operation == "test" and (await server.body(request)).get("role") == "llm"
)
if is_llm and llm and llm.protocol == "codex" and not visitor.codex_reserved:
raise DecisionError("codex_chatgpt_login_required", 409)
manager.rate(request[CLIENT_IP], "calls", manager.limits.ip_calls)
if manager.inference >= manager.limits.inference:
raise DecisionError("server_busy", 429)
manager.inference += 1
try:
return await {
"plan": server.plan,
"decide": server.decide,
"test": server.test_connection,
}[request.match_info["operation"]](request)
finally:
manager.inference -= 1
async def codex(request):
visitor = request[VISITOR]
s = visitor.service
operation = request.match_info["operation"]
if visitor.account_lock.locked():
raise DecisionError("subscription_busy", 409)
async with visitor.account_lock:
if visitor.closed:
raise DecisionError("session_expired", 401)
if request.method == "POST":
fields(await server.body(request), [])
s.invalidate()
if operation == "login":
manager.rate(request[CLIENT_IP], "logins", manager.limits.ip_logins)
manager.reserve_codex(visitor)
try:
value = await s.codex.login(device=True)
visitor.login_deadline = time.monotonic() + 600
except BaseException:
await manager.close_codex(visitor)
raise
elif operation in ("cancel", "logout"):
await manager.close_codex(visitor)
value = {"loggedIn": False}
elif not visitor.codex_reserved:
value = {"loggedIn": False, "models": [], "planningAvailable": False}
else:
value = await {
"status": s.codex.status,
"models": s.codex.models,
"limits": s.codex.limits,
}[operation]()
if operation == "status" and value.get("loggedIn"):
visitor.login_deadline = 0
return web.json_response(value)
app.router.add_get("/healthz", health)
app.router.add_post(PREFIX + "/session", session)
app.router.add_delete(PREFIX + "/session", destroy)
app.router.add_get(PREFIX + "/status", status)
app.router.add_get(PREFIX + "/models", models)
app.router.add_put(PREFIX + "/configuration", configuration)
app.router.add_post(PREFIX + "/{operation:plan|decide|test}", inference)
app.router.add_post(PREFIX + "/cancel", server.cancel)
app.router.add_get(PREFIX + "/codex/{operation:status|models|limits}", codex)
app.router.add_post(PREFIX + "/codex/{operation:login|cancel|logout}", codex)
async def reap():
while True:
await asyncio.sleep(15)
await manager.expire()
async def lifecycle(_):
async with aiohttp.ClientSession(
trust_env=False, connector=aiohttp.TCPConnector(limit=16)
) as http:
manager.http = http
reaper = asyncio.create_task(reap())
yield
reaper.cancel()
await asyncio.gather(reaper, return_exceptions=True)
await manager.close()
app.cleanup_ctx.append(lifecycle)
return app