lingling 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.
- lingling/__init__.py +21 -0
- lingling/__main__.py +4 -0
- lingling/cli.py +277 -0
- lingling/demo.py +134 -0
- lingling/health.py +222 -0
- lingling/lanes.py +598 -0
- lingling/mitm.py +334 -0
- lingling/netutil.py +245 -0
- lingling/proof.py +167 -0
- lingling/relay.py +364 -0
- lingling/winjob.py +121 -0
- lingling-2.0.0.dist-info/METADATA +143 -0
- lingling-2.0.0.dist-info/RECORD +17 -0
- lingling-2.0.0.dist-info/WHEEL +5 -0
- lingling-2.0.0.dist-info/entry_points.txt +2 -0
- lingling-2.0.0.dist-info/licenses/LICENSE +24 -0
- lingling-2.0.0.dist-info/top_level.txt +1 -0
lingling/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Lingling -- official OpenCode, but your requests ride rotating Tor lanes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
__version__ = "2.0.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def data_dir() -> Path:
|
|
11
|
+
"""Per-user runtime state (tor, lanes, proof log). Lives outside the
|
|
12
|
+
package -- a pip install must never write into site-packages."""
|
|
13
|
+
override = os.environ.get("LINGLING_DATA_DIR")
|
|
14
|
+
if override:
|
|
15
|
+
return Path(override)
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
18
|
+
return Path(base) / "lingling"
|
|
19
|
+
if sys.platform == "darwin":
|
|
20
|
+
return Path.home() / "Library" / "Application Support" / "lingling"
|
|
21
|
+
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "lingling"
|
lingling/__main__.py
ADDED
lingling/cli.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""lingling -- official OpenCode, riding rotating Tor lanes.
|
|
2
|
+
|
|
3
|
+
CLI entrypoint: boots Tor lanes, starts the local relay, then execs opencode
|
|
4
|
+
with HTTPS_PROXY pointed at it; all other args pass through untouched.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import __version__, data_dir, proof
|
|
19
|
+
from .health import HealthDaemon
|
|
20
|
+
from .lanes import TorManager
|
|
21
|
+
from .relay import Relay
|
|
22
|
+
|
|
23
|
+
DATA_DIR = data_dir()
|
|
24
|
+
PROOF_LOG = DATA_DIR / "proof.log"
|
|
25
|
+
|
|
26
|
+
DEFAULT_COUNTRIES = ["us", "de", "nl", "fr", "ro", "gb", "ca", "se", "pl", "ch"]
|
|
27
|
+
|
|
28
|
+
_KITCHEN_LINES = [
|
|
29
|
+
"cooking the lanes", "baking it", "warming the exits",
|
|
30
|
+
"glazing the tunnel", "seasoning the circuits", "proofing the dough",
|
|
31
|
+
"preheating the relays", "tasting the traffic",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
35
|
+
|
|
36
|
+
_PHRASE_COLORS = ["38;5;215", "38;5;222", "38;5;180", "38;5;173",
|
|
37
|
+
"38;5;114", "38;5;109", "38;5;139", "38;5;175"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _c(text: str, code: str) -> str:
|
|
41
|
+
if os.environ.get("NO_COLOR") or not sys.stdout.isatty():
|
|
42
|
+
return text
|
|
43
|
+
return f"\x1b[{code}m{text}\x1b[0m"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Loader:
|
|
47
|
+
"""Self-rewriting status line; deliberately vibe-only, no lane counts or progress bar."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._stop = threading.Event()
|
|
51
|
+
self._thread: threading.Thread | None = None
|
|
52
|
+
|
|
53
|
+
def set(self, detail: str = "") -> None:
|
|
54
|
+
# Kept for API compatibility; details are intentionally not shown.
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def start(self) -> None:
|
|
58
|
+
if not sys.stdout.isatty():
|
|
59
|
+
return
|
|
60
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
61
|
+
self._thread.start()
|
|
62
|
+
|
|
63
|
+
def _run(self) -> None:
|
|
64
|
+
tick = itertools.count()
|
|
65
|
+
while not self._stop.is_set():
|
|
66
|
+
t = next(tick)
|
|
67
|
+
spin = _c(_SPIN[t % len(_SPIN)], "1;38;5;220")
|
|
68
|
+
idx = (t // 24) % len(_KITCHEN_LINES)
|
|
69
|
+
msg = _c(_KITCHEN_LINES[idx], _PHRASE_COLORS[idx])
|
|
70
|
+
sys.stdout.write(f"\r\x1b[K {spin} {msg}")
|
|
71
|
+
sys.stdout.flush()
|
|
72
|
+
time.sleep(0.09)
|
|
73
|
+
|
|
74
|
+
def stop(self, final: str = "") -> None:
|
|
75
|
+
self._stop.set()
|
|
76
|
+
if self._thread:
|
|
77
|
+
self._thread.join(timeout=2)
|
|
78
|
+
self._thread = None
|
|
79
|
+
if sys.stdout.isatty():
|
|
80
|
+
sys.stdout.write("\r\x1b[K")
|
|
81
|
+
if final:
|
|
82
|
+
sys.stdout.write(final + "\n")
|
|
83
|
+
sys.stdout.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_args(argv: list[str]) -> dict:
|
|
87
|
+
"""Split lingling's own flags from args passed through to opencode."""
|
|
88
|
+
opts = {
|
|
89
|
+
"lanes": int(os.environ.get("LINGLING_TOR_COUNT", "5") or 5),
|
|
90
|
+
"no_tor": False,
|
|
91
|
+
"no_proof": False,
|
|
92
|
+
"proof_tail": None,
|
|
93
|
+
"demo": False,
|
|
94
|
+
"passthrough": [],
|
|
95
|
+
}
|
|
96
|
+
skip = False
|
|
97
|
+
for i, a in enumerate(argv):
|
|
98
|
+
if skip:
|
|
99
|
+
skip = False
|
|
100
|
+
continue
|
|
101
|
+
if a == "--proof":
|
|
102
|
+
opts["proof_tail"] = argv[i + 1] if i + 1 < len(argv) else ""
|
|
103
|
+
skip = True
|
|
104
|
+
elif a == "--demo":
|
|
105
|
+
opts["demo"] = True
|
|
106
|
+
elif a == "--lanes":
|
|
107
|
+
try:
|
|
108
|
+
opts["lanes"] = max(1, int(argv[i + 1]))
|
|
109
|
+
opts["lanes_explicit"] = True
|
|
110
|
+
except (IndexError, ValueError):
|
|
111
|
+
pass
|
|
112
|
+
skip = True
|
|
113
|
+
elif a == "--no-tor":
|
|
114
|
+
opts["no_tor"] = True
|
|
115
|
+
elif a == "--no-proof":
|
|
116
|
+
opts["no_proof"] = True
|
|
117
|
+
else:
|
|
118
|
+
opts["passthrough"].append(a)
|
|
119
|
+
return opts
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str]) -> int:
|
|
123
|
+
opts = _parse_args(argv)
|
|
124
|
+
|
|
125
|
+
if opts["proof_tail"] is not None:
|
|
126
|
+
return proof.tail(Path(opts["proof_tail"] or str(PROOF_LOG)))
|
|
127
|
+
|
|
128
|
+
if opts["demo"]:
|
|
129
|
+
from .demo import run_demo
|
|
130
|
+
question = " ".join(opts["passthrough"]).strip() or (
|
|
131
|
+
"In one sentence: who are you, and what exit node do you think "
|
|
132
|
+
"this request came from?")
|
|
133
|
+
lanes = opts["lanes"] if opts.get("lanes_explicit") else 2
|
|
134
|
+
return run_demo(question, lanes=lanes)
|
|
135
|
+
|
|
136
|
+
if "--version" in opts["passthrough"] or "-v" in opts["passthrough"]:
|
|
137
|
+
print(f"lingling {__version__} (wraps opencode)")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
opencode = shutil.which("opencode")
|
|
141
|
+
if opencode is None:
|
|
142
|
+
print("lingling: couldn't find `opencode` on your PATH.")
|
|
143
|
+
print("Install it first (https://opencode.ai) and re-run.")
|
|
144
|
+
return 1
|
|
145
|
+
|
|
146
|
+
loader = _Loader()
|
|
147
|
+
loader.start()
|
|
148
|
+
manager: TorManager | None = None
|
|
149
|
+
daemon: HealthDaemon | None = None
|
|
150
|
+
relay: Relay | None = None
|
|
151
|
+
direct = opts["no_tor"]
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
if not direct:
|
|
155
|
+
manager = TorManager(
|
|
156
|
+
DATA_DIR, count=opts["lanes"],
|
|
157
|
+
exit_countries=DEFAULT_COUNTRIES,
|
|
158
|
+
tor_exe=os.environ.get("LINGLING_TOR_EXE", ""),
|
|
159
|
+
log=lambda *a: None,
|
|
160
|
+
)
|
|
161
|
+
loader.set()
|
|
162
|
+
err = manager.setup_lanes()
|
|
163
|
+
if err:
|
|
164
|
+
loader.stop(_c(f" !! tor unavailable ({err}) -- going direct",
|
|
165
|
+
"33"))
|
|
166
|
+
direct = True
|
|
167
|
+
else:
|
|
168
|
+
emit = proof.make_emitter(PROOF_LOG)
|
|
169
|
+
daemon = HealthDaemon(manager, event=emit,
|
|
170
|
+
log=lambda *a: None)
|
|
171
|
+
|
|
172
|
+
# Boot order: only lane 1 cooks in the foreground; the rest follow in background.
|
|
173
|
+
first = manager.lanes[0]
|
|
174
|
+
manager.start_lanes([first])
|
|
175
|
+
|
|
176
|
+
# Block until lane 1 provably carries traffic, else the user's first prompt dies.
|
|
177
|
+
deadline = time.time() + 150
|
|
178
|
+
while time.time() < deadline:
|
|
179
|
+
verdict = daemon.probe_lane(first)
|
|
180
|
+
if verdict == "healthy":
|
|
181
|
+
first.healthy = True
|
|
182
|
+
first.unhealthy_cycles = 0
|
|
183
|
+
break
|
|
184
|
+
if verdict == "burned":
|
|
185
|
+
first.burned_cycles += 1
|
|
186
|
+
daemon._heal_burn(first)
|
|
187
|
+
time.sleep(2)
|
|
188
|
+
if first.healthy is not True:
|
|
189
|
+
loader.stop(_c(" !! the kitchen stayed cold -- "
|
|
190
|
+
"going direct", "31"))
|
|
191
|
+
direct = True
|
|
192
|
+
manager.stop_all()
|
|
193
|
+
daemon.start()
|
|
194
|
+
|
|
195
|
+
if direct:
|
|
196
|
+
loader.stop()
|
|
197
|
+
print(_c("lingling: no lanes -- opencode rides your own IP.\n",
|
|
198
|
+
"33"))
|
|
199
|
+
return _run_opencode(opencode, opts["passthrough"], None)
|
|
200
|
+
|
|
201
|
+
emit = proof.make_emitter(PROOF_LOG)
|
|
202
|
+
relay = Relay(manager, event=emit)
|
|
203
|
+
port = relay.start()
|
|
204
|
+
|
|
205
|
+
# Per-request proof via local TLS termination; best-effort, falls back to blind tunnels.
|
|
206
|
+
ca_pem = None
|
|
207
|
+
if os.environ.get("LINGLING_NO_MITM", "").lower() not in ("1", "true"):
|
|
208
|
+
try:
|
|
209
|
+
from . import mitm
|
|
210
|
+
relay.cert_shop = mitm.CertShop(DATA_DIR / "mitm")
|
|
211
|
+
ca_pem = relay.cert_shop.ca_pem_path
|
|
212
|
+
except Exception: # noqa: BLE001
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
loader.stop(_c(" served! lanes are hot -- proof is in the other "
|
|
216
|
+
"window.", "1;32"))
|
|
217
|
+
if not opts["no_proof"]:
|
|
218
|
+
proof.spawn_proof_window(PROOF_LOG)
|
|
219
|
+
|
|
220
|
+
# Remaining lanes bootstrap in background; health probes join them to rotation as they come up.
|
|
221
|
+
rest = manager.lanes[1:]
|
|
222
|
+
if rest:
|
|
223
|
+
def _cook_rest() -> None:
|
|
224
|
+
for lane in rest:
|
|
225
|
+
emit({"type": "lane", "kind": "heal", "t": time.time(),
|
|
226
|
+
"lane": lane.index, "cc": lane.exit_country,
|
|
227
|
+
"ip": "",
|
|
228
|
+
"msg": f"lane {lane.index} {{{lane.exit_country}}} "
|
|
229
|
+
f"registering in the background ..."})
|
|
230
|
+
manager.start_lanes(rest)
|
|
231
|
+
|
|
232
|
+
threading.Thread(target=_cook_rest, name="lane-cook",
|
|
233
|
+
daemon=True).start()
|
|
234
|
+
|
|
235
|
+
env = dict(os.environ)
|
|
236
|
+
for var in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"):
|
|
237
|
+
env[var] = f"http://127.0.0.1:{port}"
|
|
238
|
+
env["NO_PROXY"] = env["no_proxy"] = "localhost,127.0.0.1"
|
|
239
|
+
if ca_pem:
|
|
240
|
+
# Let opencode trust our local CA so we can log each model call.
|
|
241
|
+
env["NODE_EXTRA_CA_CERTS"] = str(ca_pem)
|
|
242
|
+
return _run_opencode(opencode, opts["passthrough"], env)
|
|
243
|
+
finally:
|
|
244
|
+
if daemon:
|
|
245
|
+
daemon.stop()
|
|
246
|
+
if relay:
|
|
247
|
+
relay.stop()
|
|
248
|
+
if manager and not direct:
|
|
249
|
+
manager.stop_all()
|
|
250
|
+
if not direct:
|
|
251
|
+
try:
|
|
252
|
+
proof.make_emitter(PROOF_LOG)(proof.DONE)
|
|
253
|
+
except Exception: # noqa: BLE001
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _run_opencode(binary: str, args: list[str],
|
|
258
|
+
env: dict | None) -> int:
|
|
259
|
+
"""Exec opencode with stdio inherited so it owns the terminal."""
|
|
260
|
+
try:
|
|
261
|
+
proc = subprocess.Popen([binary, *args], env=env)
|
|
262
|
+
except OSError as exc:
|
|
263
|
+
print(f"lingling: couldn't launch opencode: {exc}")
|
|
264
|
+
return 1
|
|
265
|
+
try:
|
|
266
|
+
return proc.wait()
|
|
267
|
+
except KeyboardInterrupt:
|
|
268
|
+
try:
|
|
269
|
+
proc.terminate()
|
|
270
|
+
proc.wait(timeout=5)
|
|
271
|
+
except Exception: # noqa: BLE001
|
|
272
|
+
pass
|
|
273
|
+
return 130
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def entry() -> None:
|
|
277
|
+
sys.exit(main(sys.argv[1:]))
|
lingling/demo.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""``lingling --demo`` -- fire one real Muse Spark request through a real
|
|
2
|
+
lane and show the receipts: which lane, which exit IP, and the reply.
|
|
3
|
+
|
|
4
|
+
Muse Spark lives only on the Responses API (``POST /zen/v1/responses``), not chat/completions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from . import data_dir, netutil
|
|
13
|
+
from .health import UPSTREAM_HOST, UPSTREAM_UA, HealthDaemon
|
|
14
|
+
from .lanes import TorManager
|
|
15
|
+
|
|
16
|
+
DEFAULT_COUNTRIES = ["us", "de", "nl", "fr", "ro", "gb", "ca", "se", "pl", "ch"]
|
|
17
|
+
|
|
18
|
+
MODEL = "muse-spark-1.2-contributor-free"
|
|
19
|
+
DATA_DIR = data_dir()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _say(msg: str) -> None:
|
|
23
|
+
print(msg, flush=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _extract_text(obj: dict) -> str:
|
|
27
|
+
"""Pull the assistant text out of a Responses API reply."""
|
|
28
|
+
for item in obj.get("output") or []:
|
|
29
|
+
if not isinstance(item, dict):
|
|
30
|
+
continue
|
|
31
|
+
if item.get("type") == "message":
|
|
32
|
+
parts = []
|
|
33
|
+
for c in item.get("content") or []:
|
|
34
|
+
if isinstance(c, dict) and c.get("type") == "output_text":
|
|
35
|
+
parts.append(c.get("text", ""))
|
|
36
|
+
if parts:
|
|
37
|
+
return "\n".join(parts)
|
|
38
|
+
if isinstance(obj.get("output_text"), str):
|
|
39
|
+
return obj["output_text"]
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_demo(question: str, lanes: int = 2) -> int:
|
|
44
|
+
manager = TorManager(DATA_DIR, count=lanes,
|
|
45
|
+
exit_countries=DEFAULT_COUNTRIES, log=lambda *a: None)
|
|
46
|
+
|
|
47
|
+
_say("== cooking the lanes (first run downloads tor, ~1-2 min) ==")
|
|
48
|
+
err = manager.setup_lanes()
|
|
49
|
+
if err:
|
|
50
|
+
_say(f" !! tor unavailable: {err}")
|
|
51
|
+
return 1
|
|
52
|
+
manager.start_all(on_lane=lambda lane, status: _say(
|
|
53
|
+
f" lane {lane.index} {{{lane.exit_country}}}: {status}"))
|
|
54
|
+
|
|
55
|
+
daemon = HealthDaemon(manager)
|
|
56
|
+
_say("\n== probing each lane against the real upstream ==")
|
|
57
|
+
deadline = time.time() + 150
|
|
58
|
+
ready = []
|
|
59
|
+
while time.time() < deadline:
|
|
60
|
+
for lane in manager.lanes:
|
|
61
|
+
if lane.healthy is not True:
|
|
62
|
+
verdict = daemon.probe_lane(lane)
|
|
63
|
+
lane.healthy = verdict == "healthy"
|
|
64
|
+
if verdict == "healthy":
|
|
65
|
+
_say(f" lane {lane.index} {{{lane.exit_country}}} up, "
|
|
66
|
+
f"exit IP {lane.exit_ip or '?'}")
|
|
67
|
+
elif verdict == "burned":
|
|
68
|
+
lane.burned_cycles += 1
|
|
69
|
+
daemon._heal_burn(lane)
|
|
70
|
+
ready = manager.healthy_lanes()
|
|
71
|
+
if ready:
|
|
72
|
+
break
|
|
73
|
+
time.sleep(2)
|
|
74
|
+
if not ready:
|
|
75
|
+
_say(" !! no lane came up -- cannot run the demo")
|
|
76
|
+
manager.stop_all()
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
lane = ready[0]
|
|
80
|
+
_say(f"\n== firing the request through lane {lane.index} "
|
|
81
|
+
f"{{{lane.exit_country}}}, exit IP {lane.exit_ip or '?'} ==")
|
|
82
|
+
_say(f" model: {MODEL}")
|
|
83
|
+
_say(f" you: {question}")
|
|
84
|
+
|
|
85
|
+
payload = {
|
|
86
|
+
"model": MODEL,
|
|
87
|
+
"input": [{
|
|
88
|
+
"role": "user",
|
|
89
|
+
"content": [{"type": "input_text", "text": question}],
|
|
90
|
+
}],
|
|
91
|
+
"stream": False,
|
|
92
|
+
"store": False,
|
|
93
|
+
"max_output_tokens": 4096,
|
|
94
|
+
}
|
|
95
|
+
t0 = time.time()
|
|
96
|
+
try:
|
|
97
|
+
code, body = netutil.https_via_socks(
|
|
98
|
+
lane.socks_port, UPSTREAM_HOST, "POST", "/zen/v1/responses",
|
|
99
|
+
UPSTREAM_UA, body=json.dumps(payload).encode(),
|
|
100
|
+
timeout=180.0)
|
|
101
|
+
except Exception as exc: # noqa: BLE001
|
|
102
|
+
_say(f" !! the lane dropped the request: {exc}")
|
|
103
|
+
manager.stop_all()
|
|
104
|
+
return 1
|
|
105
|
+
dt = time.time() - t0
|
|
106
|
+
|
|
107
|
+
if code == 429:
|
|
108
|
+
_say(" !! 429 from upstream -- the lane would now be re-cooked "
|
|
109
|
+
"(that's the rotation working)")
|
|
110
|
+
manager.stop_all()
|
|
111
|
+
return 1
|
|
112
|
+
if code != 200:
|
|
113
|
+
_say(f" !! upstream answered HTTP {code}: {body[:400]!r}")
|
|
114
|
+
manager.stop_all()
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
obj = json.loads(body)
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
_say(f" !! non-JSON reply: {body[:400]!r}")
|
|
121
|
+
manager.stop_all()
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
text = _extract_text(obj)
|
|
125
|
+
usage = obj.get("usage") or {}
|
|
126
|
+
_say(f"\n== {MODEL} answered through lane {lane.index} in {dt:.1f}s ==")
|
|
127
|
+
_say(f" exit IP seen by upstream: {lane.exit_ip or '?'}")
|
|
128
|
+
if usage:
|
|
129
|
+
_say(f" tokens: {json.dumps(usage)}")
|
|
130
|
+
_say("")
|
|
131
|
+
_say(text or "(empty reply)")
|
|
132
|
+
_say("")
|
|
133
|
+
manager.stop_all()
|
|
134
|
+
return 0
|
lingling/health.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Lane health daemon -- this is what makes rate limits invisible.
|
|
2
|
+
|
|
3
|
+
A TLS tunnel can't be inspected by the relay, so each lane periodically
|
|
4
|
+
probes the real upstream through its own SOCKS port and is healed (restarted
|
|
5
|
+
or regenerated from scratch) before your next request would have used it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Callable, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from . import netutil
|
|
17
|
+
from .lanes import Lane, TorManager
|
|
18
|
+
|
|
19
|
+
UPSTREAM_HOST = "opencode.ai"
|
|
20
|
+
UPSTREAM_PROBE_PATH = "/zen/v1/models"
|
|
21
|
+
# The free tier instantly 429s requests without this UA; a probe lacking it would call every lane burned.
|
|
22
|
+
UPSTREAM_UA = os.environ.get("LINGLING_UPSTREAM_USER_AGENT", "opencode/1.0")
|
|
23
|
+
|
|
24
|
+
PROBE_TIMEOUT = 15.0
|
|
25
|
+
# Consecutive failed cycles before a dead lane escalates from restart to regenerate.
|
|
26
|
+
_ESCALATE_AFTER = 2
|
|
27
|
+
# Consecutive 429 probes before cheap heals give way to rotating the exit country + regenerating.
|
|
28
|
+
_BURN_ESCALATE_AFTER = 3
|
|
29
|
+
# Min gap between regenerates; Tor bootstrap is 30-90s and re-rolling faster just keeps the lane booting.
|
|
30
|
+
_REGEN_COOLDOWN_S = 1200.0
|
|
31
|
+
# A sidelined lane gets one re-probe after this long; blocks do lift.
|
|
32
|
+
_SIDELINE_RECHECK_S = 3600.0
|
|
33
|
+
_FAST_FAIL_CYCLES = 4
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HealthDaemon:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
tor: TorManager,
|
|
40
|
+
check_interval: float = 45.0,
|
|
41
|
+
event: Optional[Callable[[Dict], None]] = None,
|
|
42
|
+
log: Optional[Callable[..., None]] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.tor = tor
|
|
45
|
+
self.check_interval = check_interval
|
|
46
|
+
# ``event`` receives proof-log dicts ({"type": "lane", ...}).
|
|
47
|
+
self._emit = event or (lambda e: None)
|
|
48
|
+
self.log = log or (lambda *a, **k: None)
|
|
49
|
+
self._stop = threading.Event()
|
|
50
|
+
self._thread: Optional[threading.Thread] = None
|
|
51
|
+
self._warmup = True
|
|
52
|
+
|
|
53
|
+
def start(self) -> None:
|
|
54
|
+
if self._thread is not None and self._thread.is_alive():
|
|
55
|
+
return
|
|
56
|
+
self._stop.clear()
|
|
57
|
+
self._thread = threading.Thread(
|
|
58
|
+
target=self._loop, name="lane-health", daemon=True)
|
|
59
|
+
self._thread.start()
|
|
60
|
+
|
|
61
|
+
def stop(self) -> None:
|
|
62
|
+
self._stop.set()
|
|
63
|
+
if self._thread:
|
|
64
|
+
self._thread.join(timeout=5)
|
|
65
|
+
self._thread = None
|
|
66
|
+
|
|
67
|
+
def _loop(self) -> None:
|
|
68
|
+
while not self._stop.is_set():
|
|
69
|
+
try:
|
|
70
|
+
self.check_once()
|
|
71
|
+
except Exception as exc: # noqa: BLE001
|
|
72
|
+
self.log("health: cycle flamed out: %s", exc)
|
|
73
|
+
self._stop.wait(self.check_interval)
|
|
74
|
+
|
|
75
|
+
def probe_lane(self, lane: Lane) -> str:
|
|
76
|
+
"""One probe round. Returns "healthy" | "burned" | "dead"."""
|
|
77
|
+
if not netutil.port_is_open("127.0.0.1", lane.socks_port,
|
|
78
|
+
timeout=netutil.PORT_CHECK_TIMEOUT):
|
|
79
|
+
return "dead"
|
|
80
|
+
try:
|
|
81
|
+
code, _ = netutil.https_get_via_socks(
|
|
82
|
+
lane.socks_port, UPSTREAM_HOST, UPSTREAM_PROBE_PATH,
|
|
83
|
+
UPSTREAM_UA, timeout=PROBE_TIMEOUT)
|
|
84
|
+
if code == 429:
|
|
85
|
+
return "burned"
|
|
86
|
+
if code == 0:
|
|
87
|
+
return "dead"
|
|
88
|
+
except Exception: # noqa: BLE001
|
|
89
|
+
return "dead"
|
|
90
|
+
# Lane is carrying traffic; fingerprint its exit IP for the proof pane.
|
|
91
|
+
try:
|
|
92
|
+
code, body = netutil.https_get_via_socks(
|
|
93
|
+
lane.socks_port, "check.torproject.org", "/api/ip",
|
|
94
|
+
UPSTREAM_UA, timeout=PROBE_TIMEOUT)
|
|
95
|
+
if code == 200:
|
|
96
|
+
obj = json.loads(body.decode("utf-8", "replace"))
|
|
97
|
+
if obj.get("IsTor") and obj.get("IP"):
|
|
98
|
+
lane.exit_ip = str(obj["IP"])
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
pass
|
|
101
|
+
return "healthy"
|
|
102
|
+
|
|
103
|
+
def check_once(self) -> None:
|
|
104
|
+
for lane in self.tor.lanes:
|
|
105
|
+
if self._stop.is_set():
|
|
106
|
+
return
|
|
107
|
+
if lane.healing:
|
|
108
|
+
continue
|
|
109
|
+
if lane.sidelined:
|
|
110
|
+
self._maybe_revive(lane)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
verdict = self.probe_lane(lane)
|
|
114
|
+
if verdict == "healthy":
|
|
115
|
+
was = lane.healthy
|
|
116
|
+
lane.healthy = True
|
|
117
|
+
lane.unhealthy_cycles = 0
|
|
118
|
+
lane.burned_cycles = 0
|
|
119
|
+
if was is not True:
|
|
120
|
+
self._emit_lane(lane, "up",
|
|
121
|
+
f"lane {lane.index} {{{lane.exit_country}}} "
|
|
122
|
+
f"is cooking -- exit {lane.exit_ip or '?'}")
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
lane.healthy = False
|
|
126
|
+
# Warmup grace: first failed probe on a live port means the first circuit is still building.
|
|
127
|
+
if self._warmup and netutil.port_is_open(
|
|
128
|
+
"127.0.0.1", lane.socks_port, timeout=netutil.PORT_CHECK_TIMEOUT):
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if verdict == "burned":
|
|
132
|
+
lane.unhealthy_cycles = 0
|
|
133
|
+
lane.burned_cycles += 1
|
|
134
|
+
self._heal_burn(lane)
|
|
135
|
+
else:
|
|
136
|
+
lane.burned_cycles = 0
|
|
137
|
+
lane.unhealthy_cycles += 1
|
|
138
|
+
self._heal_dead(lane)
|
|
139
|
+
self._warmup = False
|
|
140
|
+
|
|
141
|
+
def _heal_burn(self, lane: Lane) -> None:
|
|
142
|
+
"""429 from upstream: lane is already out of rotation (caller set
|
|
143
|
+
healthy=False); re-cook from scratch, rotating exit country on repeat burns."""
|
|
144
|
+
lane.healing = True
|
|
145
|
+
try:
|
|
146
|
+
cooldown_left = (_REGEN_COOLDOWN_S
|
|
147
|
+
- (time.time() - lane.last_regenerate_at))
|
|
148
|
+
if lane.last_regenerate_at and cooldown_left > 0:
|
|
149
|
+
if lane.burned_cycles <= 1:
|
|
150
|
+
self._emit_lane(
|
|
151
|
+
lane, "burn",
|
|
152
|
+
f"lane {lane.index} hit a hidden limit -- parked "
|
|
153
|
+
f"while it cools, other lanes have your traffic")
|
|
154
|
+
return
|
|
155
|
+
self._emit_lane(
|
|
156
|
+
lane, "burn",
|
|
157
|
+
f"lane {lane.index} hit a hidden limit -- your traffic moved "
|
|
158
|
+
f"to a fresh lane; re-cooking this one from scratch")
|
|
159
|
+
if lane.burned_cycles >= _BURN_ESCALATE_AFTER:
|
|
160
|
+
new_cc = self.tor.rotate_exit_country(lane)
|
|
161
|
+
if new_cc:
|
|
162
|
+
self._emit_lane(
|
|
163
|
+
lane, "rotate",
|
|
164
|
+
f"lane {lane.index} keeps burning -- re-cooking on a "
|
|
165
|
+
f"new country {{{new_cc}}}")
|
|
166
|
+
lane.last_regenerate_at = time.time()
|
|
167
|
+
lane.burned_cycles = 0
|
|
168
|
+
self.tor.regenerate_lane(lane)
|
|
169
|
+
finally:
|
|
170
|
+
lane.healing = False
|
|
171
|
+
|
|
172
|
+
def _heal_dead(self, lane: Lane) -> None:
|
|
173
|
+
if lane.unhealthy_cycles >= _FAST_FAIL_CYCLES:
|
|
174
|
+
lane.sidelined = True
|
|
175
|
+
lane.last_sideline_at = time.time()
|
|
176
|
+
self._emit_lane(lane, "sidelined",
|
|
177
|
+
f"lane {lane.index} sat out (blocked exit) -- "
|
|
178
|
+
f"will retry later")
|
|
179
|
+
return
|
|
180
|
+
lane.healing = True
|
|
181
|
+
try:
|
|
182
|
+
if lane.unhealthy_cycles <= _ESCALATE_AFTER:
|
|
183
|
+
self._emit_lane(lane, "heal",
|
|
184
|
+
f"lane {lane.index} dropped -- poking it")
|
|
185
|
+
if not self.tor.restart_lane(lane):
|
|
186
|
+
self._emit_lane(
|
|
187
|
+
lane, "fail",
|
|
188
|
+
f"lane {lane.index} would not restart -- will "
|
|
189
|
+
f"re-cook it from scratch if it stays down")
|
|
190
|
+
return
|
|
191
|
+
cooldown_left = (_REGEN_COOLDOWN_S
|
|
192
|
+
- (time.time() - lane.last_regenerate_at))
|
|
193
|
+
if lane.last_regenerate_at and cooldown_left > 0:
|
|
194
|
+
return
|
|
195
|
+
lane.last_regenerate_at = time.time()
|
|
196
|
+
self._emit_lane(lane, "heal",
|
|
197
|
+
f"lane {lane.index} stayed down -- re-cooking "
|
|
198
|
+
f"from scratch")
|
|
199
|
+
if not self.tor.regenerate_lane(lane):
|
|
200
|
+
self._emit_lane(lane, "fail",
|
|
201
|
+
f"lane {lane.index} refused to re-cook")
|
|
202
|
+
finally:
|
|
203
|
+
lane.healing = False
|
|
204
|
+
|
|
205
|
+
def _maybe_revive(self, lane: Lane) -> None:
|
|
206
|
+
if time.time() - lane.last_sideline_at < _SIDELINE_RECHECK_S:
|
|
207
|
+
return
|
|
208
|
+
if self.probe_lane(lane) == "healthy":
|
|
209
|
+
lane.sidelined = False
|
|
210
|
+
lane.unhealthy_cycles = 0
|
|
211
|
+
lane.healthy = True
|
|
212
|
+
self._emit_lane(lane, "up",
|
|
213
|
+
f"lane {lane.index} revived -- back in the kitchen")
|
|
214
|
+
else:
|
|
215
|
+
lane.last_sideline_at = time.time()
|
|
216
|
+
|
|
217
|
+
def _emit_lane(self, lane: Lane, kind: str, message: str) -> None:
|
|
218
|
+
self._emit({
|
|
219
|
+
"type": "lane", "kind": kind, "t": time.time(),
|
|
220
|
+
"lane": lane.index, "cc": lane.exit_country,
|
|
221
|
+
"ip": lane.exit_ip, "msg": message,
|
|
222
|
+
})
|