autoreg-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
261
skills/plane-so-farm-gateway/scripts/plane_full_combo.py
Normal file
261
skills/plane-so-farm-gateway/scripts/plane_full_combo.py
Normal file
@@ -0,0 +1,261 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
plane.so FULL COMBO - autoreg + workspace + API token gateway, end-to-end automated
|
||||
====================================================================================
|
||||
One account in ~20s, zero captcha. Pipeline per account:
|
||||
1. POST /auth/email-check/ -> confirm email free (MAGIC_CODE flow)
|
||||
2. POST /auth/magic-generate/ -> 6-digit code sent to mailbox
|
||||
3. IMAP poll -> read code from SUBJECT "Plane login code is NNNNNN"
|
||||
4. GET /auth/get-csrf-token/ -> csrftoken (mandatory, else CSRF failure page)
|
||||
5. POST /auth/magic-sign-up/ -> 302 + session-id cookie (account created, email auto-verified)
|
||||
6. GET /api/users/me/ -> verify session
|
||||
7. POST /api/workspaces/ -> create workspace (name+slug)
|
||||
8. POST /api/users/api-tokens/ -> issue API token (plane_api_*, 60/min, shown ONCE)
|
||||
9. GET /api/v1/users/me/ (X-API-Key) -> verify token live
|
||||
10. append full record to accounts.jsonl
|
||||
|
||||
Backend: api.plane.so (app.plane.so = SPA only, 405 on /auth).
|
||||
Throttle: auth 10/min per IP -> bulk sleeps 7s.
|
||||
Reverse source: github.com/makeplane/plane (preview branch):
|
||||
apps/api/plane/authentication/urls.py, views/app/magic.py, app/views/api.py
|
||||
|
||||
CONFIG: fill IMAP_* + EMAIL_* below, or pass --emails file (email:imap_host:imap_user:imap_pass).
|
||||
|
||||
Usage:
|
||||
python plane_full_combo.py <alias> # one account via +alias
|
||||
python plane_full_combo.py --bulk N [out.jsonl] # mass run
|
||||
python plane_full_combo.py --emails list.txt [out.jsonl]
|
||||
Token use afterwards:
|
||||
curl https://api.plane.so/api/v1/users/me/ -H "X-API-Key: [REDACTED]"
|
||||
workspace-scoped: https://api.plane.so/api/v1/workspaces/<slug>/projects/ etc.
|
||||
"""
|
||||
import imaplib, email as emaillib, email.utils, re, time, json, sys, random, ssl
|
||||
import urllib.request, urllib.error, http.cookiejar
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# ============ CONFIG - FILL YOUR OWN ============
|
||||
IMAP_HOST = "" # imap.gmail.com / outlook.office365.com / ...
|
||||
IMAP_USER = ""
|
||||
IMAP_PASS = "" # app password recommended
|
||||
EMAIL_DOMAIN = "" # gmail.com
|
||||
EMAIL_LOCAL = "" # yourname -> yourname+prNNNNNN@gmail.com
|
||||
WORKSPACE_NAME = "farm lab"
|
||||
WORKSPACE_SLUG_PREFIX = "farmlab"
|
||||
# ================================================
|
||||
|
||||
API = "https://api.plane.so"
|
||||
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
"Origin": "https://app.plane.so", "Referer": "https://app.plane.so/sign-up/"}
|
||||
|
||||
|
||||
def post_json(path, payload, cookie=None, csrf=None, extra=None):
|
||||
hdrs = {"Content-Type": "application/json", **UA}
|
||||
if cookie: hdrs["Cookie"] = cookie
|
||||
if csrf: hdrs["X-CSRFToken"] = csrf
|
||||
if extra: hdrs.update(extra)
|
||||
req = Request(API + path, data=json.dumps(payload).encode(), headers=hdrs)
|
||||
try:
|
||||
with urlopen(req, timeout=30) as r:
|
||||
return r.status, json.loads(r.read().decode() or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try: body = json.loads(e.read().decode() or "{}")
|
||||
except Exception: body = {}
|
||||
return e.code, body
|
||||
|
||||
|
||||
def get_csrf():
|
||||
cj = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
req = urllib.request.Request(API + "/auth/get-csrf-token/", headers=UA)
|
||||
with opener.open(req, timeout=30) as r:
|
||||
r.read()
|
||||
token = [REDACTED] for c in cj if c.name == "csrftoken"), None)
|
||||
return token, "; ".join(f"{c.name}={c.value}" for c in cj)
|
||||
|
||||
|
||||
def post_form(path, data, cookies=None, csrf=None):
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *a, **k): return None
|
||||
opener = urllib.request.build_opener(NoRedirect)
|
||||
hdrs = {"Content-Type": "application/x-www-form-urlencoded", **UA}
|
||||
if cookies: hdrs["Cookie"] = cookies
|
||||
if csrf: hdrs["X-CSRFToken"] = csrf
|
||||
req = urllib.request.Request(API + path, data=urlencode(data).encode(), headers=hdrs)
|
||||
try:
|
||||
r = opener.open(req, timeout=30)
|
||||
return r.status, r.read().decode(errors="ignore"), r.headers
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode(errors="ignore"), e.headers
|
||||
|
||||
|
||||
def get_json(path, cookie=None, api_key=None):
|
||||
hdrs = {"User-Agent": UA["User-Agent"]}
|
||||
if cookie: hdrs["Cookie"] = cookie
|
||||
if api_key: hdrs["X-API-Key"] = api_key
|
||||
req = Request(API + path, headers=hdrs)
|
||||
try:
|
||||
with urlopen(req, timeout=30) as r:
|
||||
return r.status, json.loads(r.read().decode() or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def read_otp(match_suffix, host, user, passwd, tries=24, wait=5, since_ts=None):
|
||||
for _ in range(tries):
|
||||
try:
|
||||
m = imaplib.IMAP4_SSL(host, ssl_context=ssl.create_default_context())
|
||||
m.login(user, passwd); m.select("INBOX")
|
||||
_, data = m.search(None, 'SUBJECT "Plane login code"')
|
||||
best = None
|
||||
for num in data[0].split()[-10:]:
|
||||
_, msg = m.fetch(num, "(RFC822)")
|
||||
raw = msg[0][1]
|
||||
try:
|
||||
mime = emaillib.message_from_bytes(raw)
|
||||
full = ""
|
||||
for part in mime.walk():
|
||||
if part.get_content_type() in ("text/plain", "text/html"):
|
||||
try: full += part.get_payload(decode=True).decode(errors="ignore")
|
||||
except Exception: pass
|
||||
full += " " + str(mime.get("Subject", "")) + " " + str(mime.get("To", ""))
|
||||
except Exception:
|
||||
full = raw.decode(errors="ignore")
|
||||
if match_suffix.lower() not in full.lower():
|
||||
continue
|
||||
mm = re.search(r"login code is (\d{6})", full)
|
||||
dm = re.search(r"^Date: (.+)$", raw.decode(errors="ignore"), re.M)
|
||||
ts = 0
|
||||
if dm:
|
||||
try: ts = email.utils.mktime_tz(email.utils.parsedate_tz(dm.group(1)))
|
||||
except Exception: ts = 0
|
||||
if mm and (best is None or ts >= best[0]):
|
||||
best = (ts, mm.group(1))
|
||||
m.logout()
|
||||
if best and (since_ts is None or best[0] >= since_ts - 180):
|
||||
return best[1]
|
||||
except Exception as e:
|
||||
print("imap err:", e, file=sys.stderr)
|
||||
time.sleep(wait)
|
||||
return None
|
||||
|
||||
|
||||
def full_combo(email_addr, match_suffix, imap_host, imap_user, imap_pass, slug=None):
|
||||
rec = {"email": email_addr, "ts": int(time.time())}
|
||||
# 1-2. email check + magic generate
|
||||
st, js = post_json("/auth/email-check/", {"email": email_addr})
|
||||
if js.get("existing"):
|
||||
rec["result"] = "EXISTS"; return rec
|
||||
t0 = time.time()
|
||||
st, js = post_json("/auth/magic-generate/", {"email": email_addr})
|
||||
if st != 200:
|
||||
rec["result"] = f"GEN_FAIL_{st}"; return rec
|
||||
# 3. OTP
|
||||
code = read_otp(match_suffix, imap_host, imap_user, imap_pass, since_ts=t0)
|
||||
if not code:
|
||||
rec["result"] = "NO_OTP"; return rec
|
||||
# 4-5. csrf + sign-up
|
||||
csrf_tok, csrf_cookie = get_csrf()
|
||||
st, body, hdrs = post_form("/auth/magic-sign-up/", {"email": email_addr, "code": code},
|
||||
cookies=csrf_cookie, csrf=csrf_tok)
|
||||
loc = hdrs.get("Location", "")
|
||||
if "error_code" in loc:
|
||||
rec["result"] = "SIGNUP_ERR:" + loc[:150]; return rec
|
||||
cookies = hdrs.get_all("Set-Cookie") or []
|
||||
sess = "; ".join(c.split(";")[0] for c in cookies)
|
||||
cookie = ((sess + "; ") if sess else "") + csrf_cookie
|
||||
csrf_val = next((c.split(";")[0].split("=", 1)[1] for c in cookies if c.startswith("csrftoken=")), csrf_tok)
|
||||
# 6. verify session
|
||||
st, me = get_json("/api/users/me/", cookie=cookie)
|
||||
if st != 200 or not me.get("email"):
|
||||
rec["result"] = f"SESSION_FAIL_{st}"; return rec
|
||||
rec["user_id"] = me["id"]
|
||||
# 7. create workspace
|
||||
slug = slug or (WORKSPACE_SLUG_PREFIX + str(random.randint(10000, 99999)))
|
||||
st, ws = post_json("/api/workspaces/", {"name": WORKSPACE_NAME, "slug": slug},
|
||||
cookie=cookie, csrf=csrf_val,
|
||||
extra={"Referer": "https://app.plane.so/"})
|
||||
if st not in (200, 201):
|
||||
rec["result"] = f"WS_FAIL_{st}:{str(ws)[:100]}"; rec["session_cookie"] = cookie; return rec
|
||||
rec["workspace"] = {"slug": ws.get("slug"), "id": ws.get("id")}
|
||||
# 8. API token
|
||||
st, tk = post_json("/api/users/api-tokens/", {"label": "combo-" + slug, "description": "farm"},
|
||||
cookie=cookie, csrf=csrf_val,
|
||||
extra={"Referer": "https://app.plane.so/"})
|
||||
if st not in (200, 201) or not tk.get("token"):
|
||||
rec["result"] = f"TOKEN_FAIL_{st}:{str(tk)[:100]}"; rec["session_cookie"] = cookie; return rec
|
||||
rec["api_token"] = tk["token"]
|
||||
rec["rate_limit"] = tk.get("allowed_rate_limit")
|
||||
# 9. verify token on public v1
|
||||
st, v1 = get_json("/api/v1/users/me/", api_key=tk["token"])
|
||||
rec["token_live"] = (st == 200)
|
||||
rec["session_cookie"] = cookie
|
||||
rec["result"] = "FULL_OK" if st == 200 else "TOKEN_UNVERIFIED"
|
||||
return rec
|
||||
|
||||
|
||||
def gen_alias_email():
|
||||
alias = "pr" + str(random.randint(100000, 999999))
|
||||
return f"{EMAIL_LOCAL}+{alias}@{EMAIL_DOMAIN}", alias
|
||||
|
||||
|
||||
def run_one(target, imap_host=None, imap_user=None, imap_pass=None):
|
||||
h = imap_host or IMAP_HOST; u = imap_user or IMAP_USER; p = imap_pass or IMAP_PASS
|
||||
if "@" in target:
|
||||
addr, suffix = target, target
|
||||
else:
|
||||
if not (EMAIL_LOCAL and EMAIL_DOMAIN):
|
||||
print("ERROR: fill EMAIL_LOCAL/EMAIL_DOMAIN or pass full email"); sys.exit(1)
|
||||
addr = f"{EMAIL_LOCAL}+{target}@{EMAIL_DOMAIN}"; suffix = target
|
||||
rec = full_combo(addr, suffix, h, u, p)
|
||||
print(json.dumps(rec, indent=2, default=str))
|
||||
return rec
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(__doc__); sys.exit(1)
|
||||
if args[0] == "--bulk":
|
||||
n = int(args[1]) if len(args) > 1 else 10
|
||||
outfile = args[2] if len(args) > 2 else "plane_accounts.jsonl"
|
||||
ok = 0
|
||||
with open(outfile, "a", encoding="utf-8") as f:
|
||||
for i in range(n):
|
||||
addr, alias = gen_alias_email()
|
||||
try:
|
||||
rec = full_combo(addr, alias, IMAP_HOST, IMAP_USER, IMAP_PASS)
|
||||
except Exception as e:
|
||||
rec = {"email": addr, "result": "EXC:" + str(e)}
|
||||
f.write(json.dumps(rec, default=str) + "\n"); f.flush()
|
||||
if rec.get("result") == "FULL_OK": ok += 1
|
||||
print(f"[{i+1}/{n}] {addr} -> {rec.get('result')} token={rec.get('api_token','-')[:20]} (full_ok={ok})")
|
||||
time.sleep(7)
|
||||
print(f"DONE {ok}/{n} -> {outfile}")
|
||||
elif args[0] == "--emails":
|
||||
outfile = args[2] if len(args) > 2 else "plane_accounts.jsonl"
|
||||
ok = 0
|
||||
with open(args[1], encoding="utf-8") as f, open(outfile, "a", encoding="utf-8") as o:
|
||||
for i, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
parts = line.split(":")
|
||||
if len(parts) == 4:
|
||||
addr, h, u, p = parts
|
||||
else:
|
||||
addr, h, u, p = line, IMAP_HOST, IMAP_USER, IMAP_PASS
|
||||
try:
|
||||
rec = full_combo(addr, addr, h, u, p)
|
||||
except Exception as e:
|
||||
rec = {"email": addr, "result": "EXC:" + str(e)}
|
||||
o.write(json.dumps(rec, default=str) + "\n"); o.flush()
|
||||
if rec.get("result") == "FULL_OK": ok += 1
|
||||
print(f"[{i+1}] {addr} -> {rec.get('result')} (full_ok={ok})")
|
||||
time.sleep(7)
|
||||
print(f"DONE {ok} -> {outfile}")
|
||||
else:
|
||||
run_one(args[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
475
skills/plane-so-farm-gateway/scripts/plane_gw.py
Normal file
475
skills/plane-so-farm-gateway/scripts/plane_gw.py
Normal file
@@ -0,0 +1,475 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
plane_gw.py v2 - OpenAI-compatible gateway over Plane AI (pi.plane.so)
|
||||
=======================================================================
|
||||
9Router-style: account pool, round-robin, auto-disable dead accounts, local-no-auth.
|
||||
NEW in v2:
|
||||
- TRUE token streaming (SSE delta passthrough, chunked transfer)
|
||||
- OpenAI tool-calling EMULATION: upstream pi.plane.so accepts NO client tools
|
||||
(tools are server-side: execute-action/artifacts/mcp). The gateway injects tool
|
||||
schemas into the prompt with a strict JSON protocol, parses model output, and
|
||||
returns real OpenAI tool_calls. role=tool results are folded back into the
|
||||
next prompt (full transcript per turn - stateless, reliable).
|
||||
- system prompts, multi-turn history, thinking/websearch via model params
|
||||
|
||||
Endpoints: GET /v1/models | POST /v1/chat/completions (stream+sync) | /health
|
||||
/admin/pool | /admin/reload
|
||||
Pool file: plane_accounts.jsonl (FULL_OK records from plane_full_combo.py)
|
||||
Run: python plane_gw.py [port] (default 20130)
|
||||
CLI: base_url=http://127.0.0.1:20130/v1 api_key=any model=plane/claude-sonnet-5
|
||||
Env: PLANE_ACCOUNTS=<path> GW_KEY=<bearer key for remote access>
|
||||
"""
|
||||
import json, os, sys, time, re, threading, hashlib
|
||||
import queue as pyqueue
|
||||
import urllib.request, urllib.error
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PI = "https://pi.plane.so"
|
||||
ACCOUNTS_FILE = os.getenv("PLANE_ACCOUNTS", os.path.join(os.path.dirname(os.path.abspath(__file__)), "plane_accounts.jsonl"))
|
||||
GWKEY = os.getenv("GW_KEY", "")
|
||||
POOL_LOCK = threading.Lock()
|
||||
|
||||
TOOL_PREAMBLE = """TOOL PROTOCOL:
|
||||
You have access to these tools:
|
||||
%s
|
||||
|
||||
When you want to call a tool, reply with ONLY a fenced json block in EXACTLY this format:
|
||||
```json
|
||||
{"tool_calls": [{"name": "<tool_name>", "arguments": {<json arguments per schema>}}]}
|
||||
```
|
||||
Nothing else in the message. You may request several tools in the list.
|
||||
After the user returns tool results (lines starting with "Tool <name> result:"), continue: call another tool the same way, or give your FINAL answer in plain text (no json block).
|
||||
"""
|
||||
|
||||
class Account:
|
||||
def __init__(self, rec):
|
||||
self.email = rec.get("email")
|
||||
self.cookie = rec.get("session_cookie", "")
|
||||
ws = rec.get("workspace") or {}
|
||||
self.ws_slug = ws.get("slug")
|
||||
self.ws_id = ws.get("id")
|
||||
self.user_id = rec.get("user_id")
|
||||
self.is_active = bool(self.cookie and self.ws_slug and self.ws_id)
|
||||
self.used = 0
|
||||
self.last_err = ""
|
||||
self.disabled_reason = ""
|
||||
|
||||
class Pool:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.accounts = []
|
||||
self.idx = 0
|
||||
self.reload()
|
||||
def reload(self):
|
||||
accs = []
|
||||
if os.path.exists(self.path):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
try: rec = json.loads(line)
|
||||
except Exception: continue
|
||||
if rec.get("result") == "FULL_OK": accs.append(Account(rec))
|
||||
with POOL_LOCK:
|
||||
self.accounts = accs
|
||||
self.idx = 0
|
||||
return len(accs)
|
||||
def next(self):
|
||||
with POOL_LOCK:
|
||||
live = [a for a in self.accounts if a.is_active]
|
||||
if not live: return None
|
||||
a = live[self.idx % len(live)]
|
||||
self.idx += 1
|
||||
return a
|
||||
def disable(self, acc, reason):
|
||||
with POOL_LOCK:
|
||||
acc.is_active = False
|
||||
acc.disabled_reason = reason
|
||||
def state(self):
|
||||
with POOL_LOCK:
|
||||
return [{"email": a.email, "active": a.is_active, "used": a.used,
|
||||
"err": a.last_err, "disabled": a.disabled_reason} for a in self.accounts]
|
||||
|
||||
POOL = Pool(ACCOUNTS_FILE)
|
||||
MODELS_CACHE = {"ts": 0, "models": []}
|
||||
|
||||
# ---------------- upstream ----------------
|
||||
|
||||
def _pi_request(path, acc, data=None, timeout=60):
|
||||
url = PI + path
|
||||
hdrs = {"Cookie": acc.cookie, "User-Agent": "Mozilla/5.0",
|
||||
"Origin": "https://app.plane.so", "Referer": "https://app.plane.so/"}
|
||||
body = None
|
||||
if data is not None:
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
body = json.dumps(data).encode()
|
||||
req = urllib.request.Request(url, data=body, headers=hdrs,
|
||||
method="POST" if data is not None else "GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status, r.read().decode(errors="ignore")
|
||||
|
||||
def get_models(acc):
|
||||
now = time.time()
|
||||
if MODELS_CACHE["models"] and now - MODELS_CACHE["ts"] < 3600:
|
||||
return MODELS_CACHE["models"]
|
||||
try:
|
||||
st, body = _pi_request("/api/v1/chat/get-models/?workspace_slug=" + str(acc.ws_slug), acc)
|
||||
js = json.loads(body)
|
||||
models = [m for m in js.get("models", []) if m.get("type") == "language_model"]
|
||||
if models:
|
||||
MODELS_CACHE["ts"] = now
|
||||
MODELS_CACHE["models"] = models
|
||||
return models
|
||||
except Exception:
|
||||
return MODELS_CACHE["models"]
|
||||
|
||||
def _start_stream(acc, prompt, model):
|
||||
st, body = _pi_request("/api/v1/chat/initialize-chat/", acc,
|
||||
{"workspace_in_context": False, "workspace_slug": acc.ws_slug})
|
||||
chat_id = json.loads(body).get("chat_id")
|
||||
if not chat_id:
|
||||
raise RuntimeError("init_chat %s: %s" % (st, body[:150]))
|
||||
payload = {"query": prompt, "llm": model, "is_new": True, "is_temp": True,
|
||||
"workspace_in_context": False, "mode": "ask",
|
||||
"workspace_id": acc.ws_id, "workspace_slug": acc.ws_slug,
|
||||
"chat_id": chat_id, "context": {}}
|
||||
st, body = _pi_request("/api/v1/chat/queue-answer/", acc, payload)
|
||||
tok = json.loads(body).get("stream_token")
|
||||
if not tok:
|
||||
raise RuntimeError("queue %s: %s" % (st, body[:150]))
|
||||
acc.used += 1
|
||||
return tok
|
||||
|
||||
def _iter_sse(tok, acc, timeout=180):
|
||||
"""Yield raw text chunks from the upstream SSE (delta events only)."""
|
||||
url = PI + "/api/v1/chat/stream-answer/" + tok
|
||||
req = urllib.request.Request(url, headers={"Cookie": acc.cookie, "User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
event = None
|
||||
for raw in r:
|
||||
line = raw.decode(errors="ignore").rstrip("\n")
|
||||
if line.startswith("event:"):
|
||||
event = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data = line[5:].strip()
|
||||
if event == "delta":
|
||||
try:
|
||||
chunk = json.loads(data).get("chunk", "")
|
||||
if chunk: yield chunk
|
||||
except Exception: pass
|
||||
elif event == "error":
|
||||
raise RuntimeError("stream error: " + data[:200])
|
||||
elif event == "done":
|
||||
break
|
||||
|
||||
def plane_chat(acc, prompt, model, timeout=180):
|
||||
tok = _start_stream(acc, prompt, model)
|
||||
return "".join(_iter_sse(tok, acc, timeout))
|
||||
|
||||
# ---------------- OpenAI request -> Plane prompt ----------------
|
||||
|
||||
def _msg_text(m):
|
||||
c = m.get("content", "")
|
||||
if isinstance(c, list):
|
||||
return " ".join(str(x.get("text", "")) for x in c if isinstance(x, dict))
|
||||
if c is None:
|
||||
# assistant tool_calls message
|
||||
calls = m.get("tool_calls") or []
|
||||
return "Assistant requested tool calls: " + json.dumps(
|
||||
[{"name": (t.get("function") or {}).get("name"),
|
||||
"arguments": (t.get("function") or {}).get("arguments")} for t in calls])
|
||||
return str(c)
|
||||
|
||||
def build_prompt(req):
|
||||
msgs = req.get("messages", [])
|
||||
tools = req.get("tools") or []
|
||||
sys_parts, conv = [], []
|
||||
for m in msgs:
|
||||
role = m.get("role", "user")
|
||||
text = _msg_text(m)
|
||||
if role == "system":
|
||||
sys_parts.append(text)
|
||||
elif role == "tool":
|
||||
name = m.get("name", "tool")
|
||||
conv.append("Tool %s result: %s" % (name, text))
|
||||
elif role == "assistant":
|
||||
tc = m.get("tool_calls")
|
||||
if tc:
|
||||
brief = [{"name": (t.get("function") or {}).get("name"),
|
||||
"arguments": (t.get("function") or {}).get("arguments")} for t in tc]
|
||||
conv.append("Assistant: " + json.dumps({"tool_calls": brief}))
|
||||
elif text:
|
||||
conv.append("Assistant: " + text)
|
||||
else:
|
||||
conv.append("User: " + text)
|
||||
prompt = ""
|
||||
if sys_parts:
|
||||
prompt += "System instructions:\n" + "\n".join(sys_parts) + "\n\n"
|
||||
if tools:
|
||||
schemas = []
|
||||
for t in tools:
|
||||
fn = t.get("function", t)
|
||||
schemas.append(json.dumps({"name": fn.get("name"), "description": fn.get("description", ""),
|
||||
"parameters": fn.get("parameters", {})}, ensure_ascii=False))
|
||||
prompt += TOOL_PREAMBLE % ("\n".join(schemas)) + "\n"
|
||||
prompt += "Conversation:\n" + "\n".join(conv) + "\n\nAssistant:"
|
||||
return prompt
|
||||
|
||||
# ---------------- tool-call parsing ----------------
|
||||
|
||||
TOOL_BLOCK = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.S)
|
||||
|
||||
def parse_tool_calls(text):
|
||||
"""Return (clean_text, [calls]) - calls extracted from fenced json blocks."""
|
||||
calls, clean = [], text
|
||||
for mm in list(TOOL_BLOCK.finditer(text)):
|
||||
try:
|
||||
js = json.loads(mm.group(1))
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(js, dict) and isinstance(js.get("tool_calls"), list) and js["tool_calls"]:
|
||||
ok = True
|
||||
for c in js["tool_calls"]:
|
||||
if not isinstance(c, dict) or "name" not in c:
|
||||
ok = False; break
|
||||
if ok:
|
||||
calls.extend(js["tool_calls"])
|
||||
clean = clean.replace(mm.group(0), "")
|
||||
return clean.strip(), calls
|
||||
|
||||
class StreamToolFilter:
|
||||
"""Incremental filter: pass through normal text, hold potential tool blocks."""
|
||||
def __init__(self):
|
||||
self.buf = ""
|
||||
self.in_fence = False
|
||||
self.fence_buf = ""
|
||||
self.calls = []
|
||||
self.clean_parts = []
|
||||
def feed(self, chunk):
|
||||
"""Return text safe to emit now."""
|
||||
out = []
|
||||
self.buf += chunk
|
||||
while True:
|
||||
if not self.in_fence:
|
||||
idx = self.buf.find("```")
|
||||
if idx == -1:
|
||||
keep = max(0, len(self.buf) - 3) # hold back possible partial fence
|
||||
if keep:
|
||||
out.append(self.buf[:keep]); self.clean_parts.append(self.buf[:keep])
|
||||
self.buf = self.buf[keep:]
|
||||
break
|
||||
else:
|
||||
if idx:
|
||||
out.append(self.buf[:idx]); self.clean_parts.append(self.buf[:idx])
|
||||
self.in_fence = True
|
||||
self.fence_buf = ""
|
||||
self.buf = self.buf[idx+3:]
|
||||
else:
|
||||
end = self.buf.find("```")
|
||||
if end == -1:
|
||||
self.fence_buf += self.buf; self.buf = ""
|
||||
if len(self.fence_buf) > 20000: # runaway fence = not a tool block
|
||||
out.append("```" + self.fence_buf); self.clean_parts.append("```" + self.fence_buf)
|
||||
self.fence_buf = ""; self.in_fence = False
|
||||
break
|
||||
else:
|
||||
block = self.fence_buf + self.buf[:end]
|
||||
self.buf = self.buf[end+3:]
|
||||
self.in_fence = False
|
||||
js = None
|
||||
try:
|
||||
js = json.loads(block.strip())
|
||||
is_tool = isinstance(js, dict) and isinstance(js.get("tool_calls"), list) and js["tool_calls"] \
|
||||
and all(isinstance(c, dict) and "name" in c for c in js["tool_calls"])
|
||||
except Exception:
|
||||
is_tool = False
|
||||
if is_tool:
|
||||
self.calls.extend(js["tool_calls"])
|
||||
else:
|
||||
txt = "```" + block + "```"
|
||||
out.append(txt); self.clean_parts.append(txt)
|
||||
return "".join(out)
|
||||
def finish(self):
|
||||
"""Flush held-back content at stream end. Returns (final_clean_text, calls)."""
|
||||
tail = ""
|
||||
if self.in_fence:
|
||||
tail = "```" + self.fence_buf
|
||||
tail += self.buf
|
||||
if tail:
|
||||
self.clean_parts.append(tail)
|
||||
return "".join(self.clean_parts).strip(), self.calls
|
||||
|
||||
# ---------------- streaming worker ----------------
|
||||
|
||||
def plane_stream_worker(acc, prompt, model, out_q, timeout=180):
|
||||
try:
|
||||
tok = _start_stream(acc, prompt, model)
|
||||
flt = StreamToolFilter()
|
||||
for chunk in _iter_sse(tok, acc, timeout):
|
||||
emit = flt.feed(chunk)
|
||||
if emit: out_q.put(("delta", emit))
|
||||
clean, calls = flt.finish()
|
||||
out_q.put(("final", (clean, calls)))
|
||||
except Exception as e:
|
||||
out_q.put(("err", str(e)[:200]))
|
||||
out_q.put(("end", None))
|
||||
|
||||
# ---------------- HTTP handler ----------------
|
||||
|
||||
def strip_model_prefix(m):
|
||||
return m.split("/", 1)[1] if m.startswith("plane/") else m
|
||||
|
||||
def mk_tool_calls_message(calls, model):
|
||||
tcs = []
|
||||
for i, c in enumerate(calls):
|
||||
args = c.get("arguments", {})
|
||||
if not isinstance(args, str): args = json.dumps(args, ensure_ascii=False)
|
||||
tcs.append({"id": "call_plane_%d_%d" % (int(time.time()), i), "type": "function",
|
||||
"function": {"name": c.get("name", ""), "arguments": args}})
|
||||
return {"role": "assistant", "content": None, "tool_calls": tcs}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
def log_message(self, *a): pass
|
||||
def _local(self): return self.client_address[0] in ("127.0.0.1", "::1")
|
||||
def _authed(self):
|
||||
if not GWKEY: return True
|
||||
if self._local(): return True
|
||||
auth = self.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and auth[7:] == GWKEY: return True
|
||||
if self.headers.get("x-api-key") == GWKEY: return True
|
||||
return False
|
||||
def _send(self, code, obj):
|
||||
data = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Headers", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "*")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
if path == "/health":
|
||||
return self._send(200, {"ok": True, "pool_live": len([a for a in POOL.accounts if a.is_active]), "v": 2})
|
||||
if path == "/admin/pool":
|
||||
if not self._local(): return self._send(403, {"error": "local only"})
|
||||
return self._send(200, POOL.state())
|
||||
if path == "/admin/reload":
|
||||
if not self._local(): return self._send(403, {"error": "local only"})
|
||||
return self._send(200, {"loaded": POOL.reload()})
|
||||
if not self._authed():
|
||||
return self._send(401, {"error": {"message": "API key required for remote API access", "type": "auth"}})
|
||||
if path == "/v1/models":
|
||||
acc = POOL.next()
|
||||
models = get_models(acc) if acc else []
|
||||
data = [{"id": "plane/" + m["id"], "object": "model", "created": int(time.time()),
|
||||
"owned_by": m.get("provider", "plane")} for m in models]
|
||||
return self._send(200, {"object": "list", "data": data})
|
||||
return self._send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
if path != "/v1/chat/completions":
|
||||
return self._send(404, {"error": "not found"})
|
||||
if not self._authed():
|
||||
return self._send(401, {"error": {"message": "API key required for remote API access", "type": "auth"}})
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
try:
|
||||
req = json.loads(self.rfile.read(n) or b"{}")
|
||||
except Exception:
|
||||
return self._send(400, {"error": "bad json"})
|
||||
model = strip_model_prefix(req.get("model", "claude-sonnet-5"))
|
||||
stream = bool(req.get("stream"))
|
||||
prompt = build_prompt(req)
|
||||
tried, last_err = 0, ""
|
||||
while tried < 3:
|
||||
acc = POOL.next()
|
||||
if acc is None:
|
||||
return self._send(503, {"error": {"message": "No active accounts in pool", "type": "pool"}})
|
||||
tried += 1
|
||||
try:
|
||||
if stream:
|
||||
return self._stream_response(acc, prompt, model)
|
||||
text = plane_chat(acc, prompt, model)
|
||||
clean, calls = parse_tool_calls(text)
|
||||
msg = mk_tool_calls_message(calls, model) if calls else {"role": "assistant", "content": clean}
|
||||
resp = {"id": "chatcmpl-plane-" + hashlib.sha1(prompt.encode()).hexdigest()[:12],
|
||||
"object": "chat.completion", "created": int(time.time()), "model": model,
|
||||
"choices": [{"index": 0, "message": msg,
|
||||
"finish_reason": "tool_calls" if calls else "stop"}],
|
||||
"usage": {"prompt_tokens": len(prompt)//4, "completion_tokens": len(text)//4,
|
||||
"total_tokens": (len(prompt)+len(text))//4}}
|
||||
return self._send(200, resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = "HTTP %s" % e.code
|
||||
acc.last_err = last_err
|
||||
if e.code in (401, 403): POOL.disable(acc, "auth %s" % e.code)
|
||||
elif e.code == 429: POOL.disable(acc, "credits/ratelimit 429")
|
||||
except Exception as e:
|
||||
last_err = str(e)[:150]
|
||||
acc.last_err = last_err
|
||||
return self._send(502, {"error": {"message": "upstream failed: " + last_err, "type": "upstream"}})
|
||||
|
||||
def _stream_response(self, acc, prompt, model):
|
||||
q = pyqueue.Queue()
|
||||
t = threading.Thread(target=plane_stream_worker, args=(acc, prompt, model, q), daemon=True)
|
||||
t.start()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
cid = "chatcmpl-plane-" + hashlib.sha1((prompt + str(time.time())).encode()).hexdigest()[:12]
|
||||
def send_chunk(payload):
|
||||
b = payload.encode()
|
||||
self.wfile.write(("%X\r\n" % len(b)).encode() + b + b"\r\n")
|
||||
self.wfile.flush()
|
||||
def base(delta, finish=None):
|
||||
return {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
|
||||
"model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
|
||||
send_chunk("data: " + json.dumps(base({"role": "assistant", "content": ""})) + "\n\n")
|
||||
calls = []
|
||||
while True:
|
||||
kind, payload = q.get()
|
||||
if kind == "delta":
|
||||
send_chunk("data: " + json.dumps(base({"content": payload})) + "\n\n")
|
||||
elif kind == "final":
|
||||
clean, calls = payload
|
||||
if calls:
|
||||
for i, c in enumerate(calls):
|
||||
args = c.get("arguments", {})
|
||||
if not isinstance(args, str): args = json.dumps(args, ensure_ascii=False)
|
||||
d = {"tool_calls": [{"index": i, "id": "call_plane_%d_%d" % (int(time.time()), i),
|
||||
"type": "function",
|
||||
"function": {"name": c.get("name", ""), "arguments": args}}]}
|
||||
send_chunk("data: " + json.dumps(base(d)) + "\n\n")
|
||||
elif kind == "err":
|
||||
send_chunk("data: " + json.dumps({"error": {"message": payload}}) + "\n\n")
|
||||
elif kind == "end":
|
||||
break
|
||||
finish = "tool_calls" if calls else "stop"
|
||||
try:
|
||||
send_chunk("data: " + json.dumps(base({}, finish)) + "\n\n")
|
||||
send_chunk("data: [DONE]\n\n")
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
self.wfile.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def main():
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 20130
|
||||
n = POOL.reload()
|
||||
print("[plane_gw v2] pool: %d accounts from %s" % (n, ACCOUNTS_FILE))
|
||||
print("[plane_gw v2] listening on http://127.0.0.1:%d/v1 (auth=%s, tools=emulated, streaming=true)" % (port, "on" if GWKEY else "off"))
|
||||
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user