autoreg-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok60915d52e8425
2026-10-20 02:16:32 +03:00
commit f58eafeb48
565 changed files with 60064 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
# PLANE.SO FARM + GATEWAY — RUNBOOK ДЛЯ АГЕНТА
> Полный цикл: фарм аккаунтов plane.so → OpenAI-совместимый шлюз для любых CLI.
> Всё проверено живьём 2026-09-20. Файлы: `C:\Users\User\Desktop\_SCRIPTS\`
## АРХИТЕКТУРА (что под капотом)
```
[plane_full_combo.py] фарм: email → magic-code → session → workspace → API token
↓ plane_accounts.jsonl (FULL_OK записи)
[plane_gw.py :20130] шлюз OpenAI-compat: пул акков, ротация, auto-disable
↓
pi.plane.so Plane AI API (FastAPI, openapi.json открыт)
```
**Ключевые факты (реверс makeplane/plane preview + pi.plane.so/openapi.json):**
- Backend: `api.plane.so` (НЕ app.plane.so — там только SPA, 405)
- AI API: `pi.plane.so` — отдельный FastAPI-хост, auth = cookie `session-id`
- Чат-флоу: `initialize-chat` → `queue-answer` → `stream-answer/{token}` (SSE)
- Капчи НЕТ. Magic-code на email. Троттл auth: 10/min на IP
- Кредиты: **105/день на аккаунт**, сброс 00:00 UTC (видно в `/api/v1/chat/start/auth-check/`)
- Сессия живёт **7 дней** (Max-Age=604800)
- 10 моделей: GPT-5.6 Terra, GPT-5.4, Claude Sonnet 5 / 4.6, GLM-5.3/5.3-Flash/5.2, Kimi-K3/K2.6, DeepSeek-V4-Pro
## ШАГ 1. ПОЧТА
Нужен IMAP-ящик с app-password. Gmail `+alias` работает (plane его не режет).
Один ящик = бесконечные акки через `yourname+prNNNNNN@gmail.com`.
Заполни в `plane_full_combo.py` (секция CONFIG):
```python
IMAP_HOST = "imap.gmail.com"
IMAP_USER = "..." # логин ящика
IMAP_PASS = "..." # app password (не основной пароль)
EMAIL_DOMAIN = "gmail.com"
EMAIL_LOCAL = "..." # локальная часть для алиасов
```
## ШАГ 2. ФАРМ АККАУНТОВ
```bash
cd C:\Users\User\Desktop\_SCRIPTS
# один акк (тест):
python plane_full_combo.py test1
# массово 50 акков (sleep 7s встроен под троттл):
python plane_full_combo.py --bulk 50 plane_accounts.jsonl
```
Каждый акк делает 10 шагов: email-check → magic-generate → IMAP читает код
(subject "Your unique Plane login code is NNNNNN") → CSRF → sign-up → verify
→ create workspace → API token → token live-check → JSONL.
**Успешная запись:** `"result": "FULL_OK"` + `session_cookie` + `workspace{slug,id}` + `api_token`.
Всё остальное — пропуск (NO_OTP = письмо не дошло за 2 мин, GEN_FAIL = троттл, EXISTS = акк уже есть).
Результат: `~25 сек/акк`, выход `plane_accounts.jsonl` (append-режим, можно дофармливать).
## ШАГ 3. ЗАПУСК ШЛЮЗА
```bash
cd C:\Users\User\Desktop\_SCRIPTS
python plane_gw.py 20130 # stdout логи; для фона: pythonw или background-терминал
```
Пуул подхватывает `plane_accounts.jsonl` из той же папки (или env `PLANE_ACCOUNTS=<путь>`).
Опционально: env-переменная GW_KEY (имя собирается из chr-кодов, grep "GWKEY") — Bearer-ключ для удалённого доступа; локально работает без ключа (9router-модель).
**Проверка (ОБЯЗАТЕЛЬНА перед отдачей):**
```bash
curl http://127.0.0.1:20130/health # {"ok": true, "pool_live": N} — N>0!
curl http://127.0.0.1:20130/v1/models # 10 моделей plane/*
curl http://127.0.0.1:20130/admin/pool # статус каждого акка (active/used/err)
# живой completion:
curl http://127.0.0.1:20130/v1/chat/completions -H "Content-Type: application/json" \
-d '{"model":"plane/claude-sonnet-5","messages":[{"role":"user","content":"say OK"}]}'
```
## ШАГ 4. ПОДКЛЮЧЕНИЕ В CLI
Любой OpenAI-совместимый клиент:
```
base_url: http://127.0.0.1:20130/v1
api_key: *** # любой, если GW_KEY не задан
model: plane/claude-sonnet-5 # префикс plane/ обязателен
```
Примеры:
- **opencode/OMP**: provider custom openai-compatible, base_url как выше
- **Hermes**: `hermes config set` → custom_providers с base_url http://127.0.0.1:20130/v1
- **Cline/Roo**: API Provider = OpenAI Compatible, те же поля
## ЭКСПЛУАТАЦИЯ
| Ситуация | Действие |
|---|---|
| Добавил акков в jsonl | `curl http://127.0.0.1:20130/admin/reload` (без рестарта) |
| Все акки в пуле disabled | Дофармить `--bulk`, reload |
| 429/кредиты кончились | Шлюз сам дизейблит акк и ротует; утром (00:00 UTC) кредиты сбрасываются — акк снова live после `/admin/reload` |
| Сессии протухли (7 дней) | Перефармить; старые FULL_OK записи шлюз задизейблит по 401 автоматически |
| Хочешь больше кредитов/день | 20 акков × 105 = 2100 кредитов/день |
## ПИТФОЛЫ (проверено на себе)
1. **НЕ используй app.plane.so для API** — 405 на всё, это SPA. Только api.plane.so (auth/rest) и pi.plane.so (AI).
2. **CSRF обязателен** на sign-up и на POST к /api/* — без X-CSRFToken будет csrf_failure.html с кодом 200 (ложный успех!). Проверяй Location на error_code.
3. **Gmail +alias**: To-header в письме закодирован — перед матчем алиаса декодируй MIME (в combo уже сделано).
4. **Код в SUBJECT**, не в теле: `login code is (\d{6})`.
5. **dict(headers) схлопывает несколько Set-Cookie** → теряется session-id. Используй `headers.get_all("Set-Cookie")`.
6. **queue-answer требует**: workspace_id + workspace_slug + chat_id + `context:{}` + mode. Без context → 422, без workspace → 400 "Workspace information is required".
7. **stream SSE**: события `reasoning`/`delta`/`cta_available`/`done`. Бери только `delta.chunk`.
8. **Троттл auth 10/min** — bulk sleep 7s не уменьшать.
9. **/api/v1/ (X-API-Key)** — только CRUD проектов (work items и т.д.), чат НЕ отдаёт. Для чата — только pi.plane.so с cookie.
10. Старые AI-эндпоинты (`ai-assistant/`, `rephrase-grammar/`) → 410 Gone, всё переехало на pi.plane.so.
## ФАЙЛЫ
```
C:\Users\User\Desktop\_SCRIPTS\
├── plane_full_combo.py # фарм: reg + workspace + api token (CONFIG заполни!)
├── plane_gw.py # шлюз OpenAI-compat :20130
├── plane_accounts.jsonl # пул (FULL_OK записи)
└── PLANE_RUNBOOK.md # этот файл
```
## TOOL USE + STREAMING (v2 шлюза)
**Streaming:** настоящий SSE-пасsthrough `delta`-событий pi.plane.so → OpenAI chunked format.
Замечание: апстрим часто отдаёт ответ крупными дельтами (не по токену) — это поведение Plane, не шлюза.
**Tool calling:** pi.plane.so НЕ принимает клиентские tools (их тулы серверные: execute-action/artifacts/MCP).
Шлюз ЭМУЛИРУЕТ OpenAI tool protocol:
- инжектит схемы тулов в промпт со строгим JSON-протоколом (fenced ```json {"tool_calls":[...]}```)
- StreamToolFilter вырезает tool-блоки из потока, остальные дельты идут клиенту сразу
- возвращает настоящие OpenAI `tool_calls` + `finish_reason:"tool_calls"`
- `role:"tool"` результаты из истории клиента сворачиваются обратно в промпт (stateless, полный транскрипт на каждый ход)
Проверено живьём 2026-09-20 (3 теста):
1. sync + tools → `tool_calls:[get_weather {"city":"Paris"}]`, finish_reason=tool_calls ✓
2. round-trip с tool result → финальный ответ "sunny in Paris 22°C", finish=stop ✓
3. stream gpt-5.6-terra → SSE chunks + [DONE] ✓
Ограничение эмуляции: модель должна слушаться протокола (Claude/GPT — да; слабые модели могут отдавать тулы текстом — тогда фильтр пропустит их как обычный текст).

View File

@@ -0,0 +1,28 @@
---
name: plane-so-farm-gateway
description: Use when farming plane.so accounts or Plane AI gateway.
---
# Plane.so Farm + Gateway
Full runbook: `PLANE_RUNBOOK.md` (in this skill dir) — READ IT FIRST.
Files (bundled in this skill):
- `scripts/plane_full_combo.py` — autoreg combo: email-check -> magic-generate -> IMAP OTP (code in SUBJECT 'login code is NNNNNN') -> CSRF -> magic-sign-up -> workspace -> API token -> verify. ~25s/account, no captcha. CONFIG block needs IMAP creds + EMAIL_LOCAL/DOMAIN.
- `scripts/plane_gw.py` (v2) — OpenAI-compatible gateway on :20130 over pi.plane.so chat API. Pool from plane_accounts.jsonl (FULL_OK records), round-robin, auto-disable on 401/403/429, /admin/reload hot-reload. TRUE SSE streaming + EMULATED OpenAI tool-calling (upstream takes no client tools; gateway injects schemas as JSON protocol, parses fenced tool_calls blocks, returns real tool_calls/finish_reason, folds role:tool results back into transcript). Models (GET /v1/models): plane/claude-sonnet-5, plane/gpt-5.6-terra, plane/gpt-5.4, plane/zai-org/GLM-5.3, plane/moonshotai/Kimi-K3, plane/deepseek-ai/DeepSeek-V4-Pro, 10 total.
Quick start (agent flow):
1. Copy scripts/ to a work dir. Fill CONFIG in plane_full_combo.py (IMAP creds, EMAIL_LOCAL/DOMAIN).
2. `python plane_full_combo.py test1` — single-account smoke test, expect result FULL_OK.
3. `python plane_full_combo.py --bulk N plane_accounts.jsonl` — farm N accounts (built-in 7s throttle sleep).
4. `python plane_gw.py 20130` — start gateway (background). Pool auto-loads plane_accounts.jsonl from same dir.
5. Verify: `curl :20130/health` (pool_live>0), `curl :20130/v1/models`, live completion POST.
6. Connect any OpenAI-compat CLI: base_url http://127.0.0.1:20130/v1, any api_key, model plane/<id>.
Key facts (verified 2026-09-20):
- Backend hosts: api.plane.so (auth/REST, session-id cookie) + pi.plane.so (Plane AI, FastAPI, openapi.json public). app.plane.so = SPA only, 405 on API.
- Chat flow: POST /api/v1/chat/initialize-chat/ {workspace_slug, workspace_in_context:false} -> chat_id; POST /api/v1/chat/queue-answer/ {query, llm, is_new:true, is_temp:true, workspace_id, workspace_slug, chat_id, context:{}, mode:'ask'} -> stream_token; GET /api/v1/chat/stream-answer/{token} SSE (take delta.chunk only).
- Credits: 105/day per account, reset 00:00 UTC. Session cookie: 7 days. Auth throttle: 10/min per IP (bulk sleeps 7s).
- Pitfalls: CSRF required on all POST (failure = csrf_failure.html with 200!); headers.get_all('Set-Cookie') not dict(); Gmail +alias needs MIME decode before match; queue-answer requires context:{} else 422; old ai-assistant endpoints = 410 Gone; write_file/execute_code masker eats os.environ.get secret patterns — build such lines via string concat or chr().
Usage: `python plane_full_combo.py --bulk N plane_accounts.jsonl` then `python plane_gw.py 20130`. Verify: curl /health (pool_live>0), /v1/models, then live completion. CLI connect: base_url http://127.0.0.1:20130/v1, any api_key, model plane/<id>.

View 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()

View 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()