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
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
252 lines
10 KiB
Python
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
|