lingling 2.0.0__tar.gz
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-2.0.0/LICENSE +24 -0
- lingling-2.0.0/PKG-INFO +143 -0
- lingling-2.0.0/README.md +118 -0
- lingling-2.0.0/lingling/__init__.py +21 -0
- lingling-2.0.0/lingling/__main__.py +4 -0
- lingling-2.0.0/lingling/cli.py +277 -0
- lingling-2.0.0/lingling/demo.py +134 -0
- lingling-2.0.0/lingling/health.py +222 -0
- lingling-2.0.0/lingling/lanes.py +598 -0
- lingling-2.0.0/lingling/mitm.py +334 -0
- lingling-2.0.0/lingling/netutil.py +245 -0
- lingling-2.0.0/lingling/proof.py +167 -0
- lingling-2.0.0/lingling/relay.py +364 -0
- lingling-2.0.0/lingling/winjob.py +121 -0
- lingling-2.0.0/lingling.egg-info/PKG-INFO +143 -0
- lingling-2.0.0/lingling.egg-info/SOURCES.txt +20 -0
- lingling-2.0.0/lingling.egg-info/dependency_links.txt +1 -0
- lingling-2.0.0/lingling.egg-info/entry_points.txt +2 -0
- lingling-2.0.0/lingling.egg-info/requires.txt +2 -0
- lingling-2.0.0/lingling.egg-info/top_level.txt +1 -0
- lingling-2.0.0/pyproject.toml +36 -0
- lingling-2.0.0/setup.cfg +4 -0
lingling-2.0.0/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Lingling License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 huranth
|
|
4
|
+
|
|
5
|
+
You are free to use and run this software, in whole or in part, for any
|
|
6
|
+
purpose, at no cost.
|
|
7
|
+
|
|
8
|
+
You may NOT:
|
|
9
|
+
- sell this software, or offer it (modified or not) as a paid product or
|
|
10
|
+
paid service;
|
|
11
|
+
- distribute modified versions of this software, or publish derivative
|
|
12
|
+
works based on it.
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in
|
|
15
|
+
all copies of the software.
|
|
16
|
+
|
|
17
|
+
All rights not expressly granted above are reserved by the copyright holder.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
23
|
+
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
24
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
lingling-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lingling
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Official OpenCode, but your requests ride rotating Tor exit lanes.
|
|
5
|
+
Author: huranth
|
|
6
|
+
License: LicenseRef-Lingling
|
|
7
|
+
Project-URL: Homepage, https://github.com/huranth/lingling
|
|
8
|
+
Project-URL: Repository, https://github.com/huranth/lingling
|
|
9
|
+
Project-URL: Issues, https://github.com/huranth/lingling/issues
|
|
10
|
+
Keywords: opencode,tor,proxy,llm,rate-limit
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: stem==1.8.2
|
|
23
|
+
Requires-Dist: cryptography>=41
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# Lingling
|
|
27
|
+
|
|
28
|
+
### What even is this?
|
|
29
|
+
|
|
30
|
+
OpenCode gives away genuinely good models for free. The catch: they throttle
|
|
31
|
+
you by IP, so right when the conversation gets good — bam, wall. Very rude.
|
|
32
|
+
|
|
33
|
+
Lingling fixes this the fun way. It spins up a little kitchen of Tor exit
|
|
34
|
+
lanes on your machine, each one a different country, and puts your requests
|
|
35
|
+
on whichever lane is hot. One exit gets throttled? Cute. Your traffic is
|
|
36
|
+
already on another one. You never see the limit. You just keep typing.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pip install lingling
|
|
40
|
+
lingling # = opencode, but the requests ride Tor
|
|
41
|
+
lingling --help # anything after `lingling` goes to opencode untouched
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
First run downloads the Tor Expert Bundle (~30 MB) into Lingling's per-user
|
|
45
|
+
data directory (`%LOCALAPPDATA%\lingling` on Windows, `~/.local/share/lingling`
|
|
46
|
+
on Linux, `~/Library/Application Support/lingling` on macOS; override with
|
|
47
|
+
`LINGLING_DATA_DIR`). After that, launching looks like this — a few seconds
|
|
48
|
+
of kitchen noises while the first lane cooks, then opencode opens:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
⠋ glazing the tunnel
|
|
52
|
+
served! lanes are hot -- proof is in the other window.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### "Sure, but how do I know it's not lying?"
|
|
56
|
+
|
|
57
|
+
Fair. That's what the proof window is for. A second console pops up next to
|
|
58
|
+
your editor and shows every single model call, live: which lane it rode,
|
|
59
|
+
which country, which exit IP, what came back, how big, how fast.
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
13:52:55 #3.1 lane 2 {de} 185.220.100.243 -> muse-spark-1.2-contributor-free
|
|
63
|
+
13:53:00 | #3.1 200 81.4 KB in 2.7s
|
|
64
|
+
13:53:01 #4.1 lane 3 {nl} 192.42.116.113 -> muse-spark-1.2-contributor-free
|
|
65
|
+
13:53:03 | #4.1 200 79.6 KB in 2.6s
|
|
66
|
+
13:53:07 * lane 4 hit a hidden limit -- your traffic moved to a fresh lane
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
No gaslighting. If the pane says bytes are flowing through a German exit,
|
|
70
|
+
your tokens are flowing through a German exit. `--no-proof` if you trust us.
|
|
71
|
+
(You shouldn't trust anyone. That's the point of the window.)
|
|
72
|
+
|
|
73
|
+
### How a limit dies without you noticing
|
|
74
|
+
|
|
75
|
+
A Tor tunnel is end-to-end TLS, so a 429 can't be spotted in flight — instead,
|
|
76
|
+
every 45 seconds each lane fires a real probe at the upstream and rats itself
|
|
77
|
+
out. The moment a lane comes back throttled:
|
|
78
|
+
|
|
79
|
+
1. it's yanked from rotation **before** your next request would have touched
|
|
80
|
+
it — your traffic just... continues, elsewhere;
|
|
81
|
+
2. the throttled lane is taken out back and re-cooked from scratch: fresh
|
|
82
|
+
tor process, fresh keys, fresh guards, fresh exit IP. We don't negotiate
|
|
83
|
+
with rate limits;
|
|
84
|
+
3. if the same lane keeps getting burned, it gets a new country too
|
|
85
|
+
(`us → de → nl → fr → ro → …`). A new personality, basically.
|
|
86
|
+
|
|
87
|
+
Lanes that just fall over get poked, restarted, re-cooked, and — if they're
|
|
88
|
+
truly hopeless — benched with a note in the proof window. You'll see the whole
|
|
89
|
+
drama live, which is honestly half the fun.
|
|
90
|
+
|
|
91
|
+
### "Wait, how can it see each request? Isn't TLS encrypted?"
|
|
92
|
+
|
|
93
|
+
Very observant. opencode holds one TLS connection open for your whole
|
|
94
|
+
session, so a normal proxy sees one opaque pipe, not individual calls. So
|
|
95
|
+
Lingling does a magic trick: it mints a throwaway certificate authority **on
|
|
96
|
+
your own machine**, hands it to opencode (and only opencode), and unwraps
|
|
97
|
+
`opencode.ai` traffic just long enough to read the model name off each
|
|
98
|
+
request before re-encrypting it through a lane to the real server.
|
|
99
|
+
|
|
100
|
+
Everything stays end-to-end TLS over Tor. The only new thing that can see
|
|
101
|
+
your traffic is... your own computer, which could already see your traffic.
|
|
102
|
+
`LINGLING_NO_MITM=1` turns the trick off if it makes you itchy (you'll get
|
|
103
|
+
per-tunnel proof instead of per-request).
|
|
104
|
+
|
|
105
|
+
### Fresh every time
|
|
106
|
+
|
|
107
|
+
Every launch wipes lane keys, guards, and circuit state — yesterday's exits
|
|
108
|
+
are dead to us. The only thing kept is tor's cached copy of the public relay
|
|
109
|
+
directory, because re-downloading that every launch is how you turn a
|
|
110
|
+
five-second boot into a two-minute boot. Orphaned `tor.exe` processes from a
|
|
111
|
+
crashed run are swept up before new lanes take their ports.
|
|
112
|
+
|
|
113
|
+
### Flags Lingling keeps for itself
|
|
114
|
+
|
|
115
|
+
| Flag | What it does |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `--lanes N` | how many lanes to keep warm (default 5, env `LINGLING_TOR_COUNT`) |
|
|
118
|
+
| `--no-tor` | skip the lanes, run opencode on your own IP like a civilian |
|
|
119
|
+
| `--no-proof` | no proof window (coward's mode) |
|
|
120
|
+
| `--demo [question]` | fire one real Muse Spark request through a lane, show receipts |
|
|
121
|
+
|
|
122
|
+
Everything else is handed to opencode byte-for-byte. We don't touch it,
|
|
123
|
+
we don't parse it, we don't want to know.
|
|
124
|
+
|
|
125
|
+
### Under the hood
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
lingling/cli.py entry point, loader, opencode launch
|
|
129
|
+
lingling/lanes.py Tor lane lifecycle: boot, regenerate, country rotation
|
|
130
|
+
lingling/relay.py local CONNECT relay, least-busy lane picking, MITM handoff
|
|
131
|
+
lingling/mitm.py local CA + per-request interception for opencode.ai
|
|
132
|
+
lingling/health.py probe daemon: burns, deaths, dramatic recoveries
|
|
133
|
+
lingling/proof.py the proof window (event log + live tail)
|
|
134
|
+
lingling/netutil.py raw SOCKS5 / HTTPS-over-SOCKS primitives
|
|
135
|
+
lingling/demo.py lingling --demo
|
|
136
|
+
lingling/winjob.py Windows Job Object so tor.exe dies with us
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Runtime state (tor, lanes, proof log, local CA) lives in the per-user data
|
|
140
|
+
directory — never in the package, never in your project folder.
|
|
141
|
+
|
|
142
|
+
Requirements: Python 3.11+, Windows/macOS/Linux, and `opencode` on your PATH.
|
|
143
|
+
Runs entirely on your machine. Nothing calls home. The kitchen is local.
|
lingling-2.0.0/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Lingling
|
|
2
|
+
|
|
3
|
+
### What even is this?
|
|
4
|
+
|
|
5
|
+
OpenCode gives away genuinely good models for free. The catch: they throttle
|
|
6
|
+
you by IP, so right when the conversation gets good — bam, wall. Very rude.
|
|
7
|
+
|
|
8
|
+
Lingling fixes this the fun way. It spins up a little kitchen of Tor exit
|
|
9
|
+
lanes on your machine, each one a different country, and puts your requests
|
|
10
|
+
on whichever lane is hot. One exit gets throttled? Cute. Your traffic is
|
|
11
|
+
already on another one. You never see the limit. You just keep typing.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install lingling
|
|
15
|
+
lingling # = opencode, but the requests ride Tor
|
|
16
|
+
lingling --help # anything after `lingling` goes to opencode untouched
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
First run downloads the Tor Expert Bundle (~30 MB) into Lingling's per-user
|
|
20
|
+
data directory (`%LOCALAPPDATA%\lingling` on Windows, `~/.local/share/lingling`
|
|
21
|
+
on Linux, `~/Library/Application Support/lingling` on macOS; override with
|
|
22
|
+
`LINGLING_DATA_DIR`). After that, launching looks like this — a few seconds
|
|
23
|
+
of kitchen noises while the first lane cooks, then opencode opens:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
⠋ glazing the tunnel
|
|
27
|
+
served! lanes are hot -- proof is in the other window.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### "Sure, but how do I know it's not lying?"
|
|
31
|
+
|
|
32
|
+
Fair. That's what the proof window is for. A second console pops up next to
|
|
33
|
+
your editor and shows every single model call, live: which lane it rode,
|
|
34
|
+
which country, which exit IP, what came back, how big, how fast.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
13:52:55 #3.1 lane 2 {de} 185.220.100.243 -> muse-spark-1.2-contributor-free
|
|
38
|
+
13:53:00 | #3.1 200 81.4 KB in 2.7s
|
|
39
|
+
13:53:01 #4.1 lane 3 {nl} 192.42.116.113 -> muse-spark-1.2-contributor-free
|
|
40
|
+
13:53:03 | #4.1 200 79.6 KB in 2.6s
|
|
41
|
+
13:53:07 * lane 4 hit a hidden limit -- your traffic moved to a fresh lane
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
No gaslighting. If the pane says bytes are flowing through a German exit,
|
|
45
|
+
your tokens are flowing through a German exit. `--no-proof` if you trust us.
|
|
46
|
+
(You shouldn't trust anyone. That's the point of the window.)
|
|
47
|
+
|
|
48
|
+
### How a limit dies without you noticing
|
|
49
|
+
|
|
50
|
+
A Tor tunnel is end-to-end TLS, so a 429 can't be spotted in flight — instead,
|
|
51
|
+
every 45 seconds each lane fires a real probe at the upstream and rats itself
|
|
52
|
+
out. The moment a lane comes back throttled:
|
|
53
|
+
|
|
54
|
+
1. it's yanked from rotation **before** your next request would have touched
|
|
55
|
+
it — your traffic just... continues, elsewhere;
|
|
56
|
+
2. the throttled lane is taken out back and re-cooked from scratch: fresh
|
|
57
|
+
tor process, fresh keys, fresh guards, fresh exit IP. We don't negotiate
|
|
58
|
+
with rate limits;
|
|
59
|
+
3. if the same lane keeps getting burned, it gets a new country too
|
|
60
|
+
(`us → de → nl → fr → ro → …`). A new personality, basically.
|
|
61
|
+
|
|
62
|
+
Lanes that just fall over get poked, restarted, re-cooked, and — if they're
|
|
63
|
+
truly hopeless — benched with a note in the proof window. You'll see the whole
|
|
64
|
+
drama live, which is honestly half the fun.
|
|
65
|
+
|
|
66
|
+
### "Wait, how can it see each request? Isn't TLS encrypted?"
|
|
67
|
+
|
|
68
|
+
Very observant. opencode holds one TLS connection open for your whole
|
|
69
|
+
session, so a normal proxy sees one opaque pipe, not individual calls. So
|
|
70
|
+
Lingling does a magic trick: it mints a throwaway certificate authority **on
|
|
71
|
+
your own machine**, hands it to opencode (and only opencode), and unwraps
|
|
72
|
+
`opencode.ai` traffic just long enough to read the model name off each
|
|
73
|
+
request before re-encrypting it through a lane to the real server.
|
|
74
|
+
|
|
75
|
+
Everything stays end-to-end TLS over Tor. The only new thing that can see
|
|
76
|
+
your traffic is... your own computer, which could already see your traffic.
|
|
77
|
+
`LINGLING_NO_MITM=1` turns the trick off if it makes you itchy (you'll get
|
|
78
|
+
per-tunnel proof instead of per-request).
|
|
79
|
+
|
|
80
|
+
### Fresh every time
|
|
81
|
+
|
|
82
|
+
Every launch wipes lane keys, guards, and circuit state — yesterday's exits
|
|
83
|
+
are dead to us. The only thing kept is tor's cached copy of the public relay
|
|
84
|
+
directory, because re-downloading that every launch is how you turn a
|
|
85
|
+
five-second boot into a two-minute boot. Orphaned `tor.exe` processes from a
|
|
86
|
+
crashed run are swept up before new lanes take their ports.
|
|
87
|
+
|
|
88
|
+
### Flags Lingling keeps for itself
|
|
89
|
+
|
|
90
|
+
| Flag | What it does |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `--lanes N` | how many lanes to keep warm (default 5, env `LINGLING_TOR_COUNT`) |
|
|
93
|
+
| `--no-tor` | skip the lanes, run opencode on your own IP like a civilian |
|
|
94
|
+
| `--no-proof` | no proof window (coward's mode) |
|
|
95
|
+
| `--demo [question]` | fire one real Muse Spark request through a lane, show receipts |
|
|
96
|
+
|
|
97
|
+
Everything else is handed to opencode byte-for-byte. We don't touch it,
|
|
98
|
+
we don't parse it, we don't want to know.
|
|
99
|
+
|
|
100
|
+
### Under the hood
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
lingling/cli.py entry point, loader, opencode launch
|
|
104
|
+
lingling/lanes.py Tor lane lifecycle: boot, regenerate, country rotation
|
|
105
|
+
lingling/relay.py local CONNECT relay, least-busy lane picking, MITM handoff
|
|
106
|
+
lingling/mitm.py local CA + per-request interception for opencode.ai
|
|
107
|
+
lingling/health.py probe daemon: burns, deaths, dramatic recoveries
|
|
108
|
+
lingling/proof.py the proof window (event log + live tail)
|
|
109
|
+
lingling/netutil.py raw SOCKS5 / HTTPS-over-SOCKS primitives
|
|
110
|
+
lingling/demo.py lingling --demo
|
|
111
|
+
lingling/winjob.py Windows Job Object so tor.exe dies with us
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Runtime state (tor, lanes, proof log, local CA) lives in the per-user data
|
|
115
|
+
directory — never in the package, never in your project folder.
|
|
116
|
+
|
|
117
|
+
Requirements: Python 3.11+, Windows/macOS/Linux, and `opencode` on your PATH.
|
|
118
|
+
Runs entirely on your machine. Nothing calls home. The kitchen is local.
|
|
@@ -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"
|
|
@@ -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:]))
|