Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 136 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,50 @@
# lanvello

one local port serving opencode free models over an openai-compatible api.
no login, no api keys from you: lanvello replays the official client
fingerprint (`opencode/*` user-agent, `x-opencode-*` session headers,
forced streaming, bash/glob/grep/read tool quartet) against
`https://opencode.ai/zen/v1` with `Bearer public`, exactly like the cli
does for its free tier. tor lanes rotate exits when an ip is throttled.
one binary, two ways at opencode's free models, egress always through tor lanes.

**`lanvello proxy` — the mode that works.** a local http proxy the real opencode
client is pointed at. opencode leases a free-tier session to the *running client*
and to nobody else: replaying the fingerprint (user-agent, `x-opencode-*`, tool
set) gets `403 FreeTierError`, and so does a session id copied from a live run,
even seconds later on the same machine. so the client keeps making its own calls
and the lanes move underneath it — a 429 on a spent exit parks that exit for the
reset window the far end named and replays the very same request on another one.

**`lanvello serve` — the openai-compatible api.** it serves free models again,
but not by replaying the client's fingerprint: the free tier leases its session
to the running opencode client, so `serve` asks that client to make the call and
translates the answer back into openai shapes (streamed chunks included).

```
./bin/lanvello serve --listen 127.0.0.1:11434 --lanes 5
inference: opencode client at http://127.0.0.1:49374 (the free-tier session lives there)
```

- `/v1/models` lists what the client itself can drive (its own list, current).
- `/v1/chat/completions` runs the caller's messages through a client session,
created per request and dropped again, so the client's session list stays
clean. `stream:false` aggregates; `stream:true` relays deltas as they arrive.
- one request per client session means requests are concurrent, and different
models can be asked for in parallel (each session is pinned to its model).
- the caller's own system prompt, history and tool definitions cross the
boundary through a spool: `serve` parks the request at
`<dataDir>/bridge/<sessionId>.json` next to the session it created, and a
plugin inside the client — `examples/lanvello-bridge.ts`, installed in
`~/.config/opencode/plugins/` — rebuilds the model call from it, so the
answer comes from the caller's prompt and the caller's tools, not the
client's. without the plugin the bridge still answers, with the client's own
agent and its own tools.
- tools are the api caller's to run. the plugin injects the caller's schemas
into the client (replacing the client's own, so a tool-less api request
cannot run anything in the client's directory), blocks the call the model
makes, records it, and `serve` returns it as an openai `tool_calls` answer
with `finish_reason: "tool_calls"` — including when the caller asked for
`stream:true`. the caller appends the result as a `role: "tool"` message and
the next request carries it back into the client.
- `--client` makes a missing client fatal; `--no-client` keeps the old replay
path, which the free tier answers with `403 FreeTierError` (see the notes at
the bottom). without a client, `serve` says so on startup instead of serving
errors.

## install

Expand All @@ -31,6 +70,7 @@ go build -o bin/lanvello ./cmd/lanvello
./bin/lanvello key add --name aider # prints sk-lanv-... once + baseURL hint
./bin/lanvello lanes status
./bin/lanvello tui
cp examples/lanvello-bridge.ts ~/.config/opencode/plugins/ # serve: caller's prompt + tools
```

any harness that accepts an openai-compatible provider then uses:
Expand All @@ -41,6 +81,62 @@ apiKey: sk-lanv-...
model: opencode/muse-spark-1.3-contributor-free
```

## proxy

```
./bin/lanvello proxy --listen 127.0.0.1:8888 --lanes 5
./bin/lanvello proxy --print-ca # the CA path, for NODE_EXTRA_CA_CERTS
```

point the client at it (the proxy prints both lines on start):

```
HTTPS_PROXY=http://[IP_ADDRESS]:8888 \
NODE_EXTRA_CA_CERTS=~/.local/state/lanvello/ca.pem \
opencode
```

the long-running client daemon — `opencode serve --service`, the process
`lanvello serve` drives — needs the same variables where *it* is started,
plus loopback kept out of the proxy:

```
NO_PROXY=127.0.0.1,localhost,::1 \
HTTPS_PROXY=http://[IP_ADDRESS]:8888 \
NODE_EXTRA_CA_CERTS=~/.local/state/lanvello/ca.pem \
opencode serve --service
```

a daemon launched without the proxy answers `403 FreeTierError` to every
session it runs, and that is the error `serve` hands back. a daemon launched
*with* the proxy but without `NO_PROXY` looks like something else again: the
client calls its own api on `127.0.0.1`, those calls go out through a lane
too, land on the exit's own loopback, and the session dies before it starts —
the proxy log shows `tls handshake opencode.ai: EOF` and the client reports
`getaddrinfo ETIMEOUT`.

what happens on the wire:

```
opencode --CONNECT opencode.ai--> lanvello proxy
| tls terminated with a ca minted in the data dir
| request replayed per lane
+--> lane 2 {de} --socks--> opencode.ai 200
+--> 429 retry-after 8760 -> lane 2 parked 146m, replay lane 3 {se} 200
```

- `--print-ca` prints `ca.pem`; the client only ever sees a leaf minted for the
host it asked for, and the CA never leaves the data dir.
- 429, 502, 503 and 504 park the exit for as long as the far end's
`retry-after` says (up to 6h) and replay the same request on the next lane
*before* a byte reaches the client: a spent exit costs a retry, not a failure.
`--quiet` drops the per-request lane log.
- `socks: ["127.0.0.1:52001", ...]` in the config reuses lanes someone else
spawned (a running `lanvello serve`, say); without it the proxy spawns its own
tor, exactly like serve.
- loopback targets bypass the lanes: the client's own local server is not
the internet, and tor would refuse it anyway.

## endpoints

- `GET /v1/models` — the live free catalog, fetched upstream through a lane
Expand Down Expand Up @@ -184,3 +280,37 @@ docker run --rm -p 127.0.0.1:11434:11434 \

see `deploy/lanvello.service` for systemd.
override target with `LANVELLO_BASE_URL` if zen ever moves.

## reverse-engineering notes (2026-10-13)

what the official client does today, read off the running binary and the live
wire — the next person to touch the free tier needs this:

- the provider moved: `https://models.opencode.ai/api.json` now serves
`"api": "https://opencode.ai/inference/v1"` for provider `opencode`. `/zen/v1`
still answers but it is the older path; the client uses `/inference/v1`.
- request shape: user agent `opencode/<channel>/<version>/<client>` (today
`opencode/dev/0.0.0-dev-20455/cli`), `authorization: Bearer public`,
`x-opencode-client`, `x-opencode-project`, `x-opencode-session` (the root
session id), `x-opencode-session-id`, `x-session-affinity`, `x-session-id`,
`b3` and `traceparent`.
- session ids are minted locally: `ses_` + `hex48(~(Date.now()*4096+counter))`
+ 14 base62 characters from random bytes. there is no signature in them; the
timestamp decodes back to the moment the session was made (counter 1).
- the model transport is a websocket (`@opencode/SessionModelTransport`,
55 minute rotation, 15s connect budget, plain http after 5 stream failures);
the free models are served over plain http today.
- the gate is a server-side lease on the session, not a fingerprint: bytes
replayed from another process — same headers in the same order and case, same
body, the client's own TLS stack, same machine — answer `403 FreeTierError`,
while the same bytes carrying the client's own session answer `200`. no
header, tool set, body, protocol, exit or address moves that line, and the
lease does not survive the client's request (a session id taken off the wire
is refused seconds later). this is why `proxy` exists and why `serve` cannot
come back.
- the free list moved with it: `fledge-alpha-free` answers RegionError,
`mimo-v2.5-free` and `exo-free` are deprecated (410); current free ids include
`mimo-v2.6-flash-free`, `step-5-preview-free`, `cat-jam-free`,
`muse-spark-1.3-contributor-free`, `nemotron-3-ultra-free`,
`nemotron-3.5-lightning-free`, `space-bunny-free`, `ling-3.0/3.1-flash`.
134 changes: 128 additions & 6 deletions cmd/lanvello/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
package main

import (
"context"
"flag"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"

"lanvello/internal/bridge"
"lanvello/internal/clientapi"
"lanvello/internal/config"
"lanvello/internal/keys"
"lanvello/internal/lanes"
"lanvello/internal/proxy"
"lanvello/internal/server"
"lanvello/internal/tor"
"lanvello/internal/upstream"
Expand All @@ -28,6 +33,8 @@ func main() {
switch os.Args[1] {
case "serve":
cmdServe(os.Args[2:])
case "proxy":
cmdProxy(os.Args[2:])
case "key":
cmdKey(os.Args[2:])
case "lanes":
Expand All @@ -48,6 +55,7 @@ func usage() {
fmt.Println(`lanvello - opencode free models over one local openai-compatible port
usage:
lanvello serve [--listen 127.0.0.1:11434] [--lanes 5] [--config file.json]
lanvello proxy [--listen 127.0.0.1:8888] [--lanes 5] [--print-ca] [--quiet]
lanvello key add --name aider
lanvello key list
lanvello key revoke --name aider
Expand Down Expand Up @@ -110,14 +118,49 @@ func healthySocks(m *lanes.Manager) int {
return n
}

func cmdServe(args []string) {
cfg := baseConfig(args)
ks, m := openDeps(cfg)
// tor-only: direct egress is gone. without tor lanes there is no service.
// defaultProxyListen is where the client is pointed: a local port, nothing
// outside the machine ever sees it.
const defaultProxyListen = "127.0.0.1:8888"

// cmdProxy runs the lane-backed MITM proxy. The free tier leases a session to
// the running client only, so the client stays the one making the calls and the
// lanes move underneath it: 429 retires the exit until retry-after and the
// request is replayed on the next one.
func cmdProxy(args []string) {
fs := flag.NewFlagSet("proxy", flag.ContinueOnError)
listen := fs.String("listen", defaultProxyListen, "listen addr of the local proxy")
printCA := fs.Bool("print-ca", false, "print the CA certificate path and exit")
quiet := fs.Bool("quiet", false, "quiet: no lane log on stderr")
ln := fs.Int("lanes", 0, "lane count")
cfgPath := fs.String("config", "", "config json path")
dataDir := fs.String("data-dir", "", "state dir")
_ = fs.Parse(args)

cfg := config.Defaults()
if *ln > 0 {
cfg.Lanes = *ln
}
if *dataDir != "" {
cfg.DataDir = *dataDir
}
var err error
cfg, err = config.LoadFile(*cfgPath, cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "config error: "+err.Error())
os.Exit(1)
}
_, m := openDeps(cfg)
ca, err := proxy.OpenCA(cfg.DataDir)
if err != nil {
fmt.Fprintln(os.Stderr, "ca: "+err.Error())
os.Exit(1)
}
if *printCA {
fmt.Println(ca.Path())
return
}
if len(cfg.Socks) == 0 {
tor.Ensure(m, cfg.DataDir, 52001, 52301, 90*time.Second)
// A 429 rotates the lane: respawn its tor in the background so the
// next request lands on a fresh exit.
m.OnRotate = func(l *lanes.Lane) {
tor.Respawn(m, l, cfg.DataDir, 52001, 52301, 90*time.Second)
}
Expand All @@ -126,6 +169,67 @@ func cmdServe(args []string) {
fmt.Fprintln(os.Stderr, "no tor lanes up (tor binary missing or bootstrap failed) -- refusing to serve direct")
os.Exit(1)
}
lnr, err := net.Listen("tcp", *listen)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
p := proxy.New(m, ca)
if !*quiet {
p.Logf = func(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
}
fmt.Printf("lanvello %s proxy on %s lanes=%d, egress only through lanes\n", version, *listen, len(m.Lanes()))
fmt.Printf(" HTTPS_PROXY=http://%s NODE_EXTRA_CA_CERTS=%s opencode\n", *listen, ca.Path())
if err := p.Serve(lnr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func cmdServe(args []string) {
cfg := baseConfig(args)
ks, m := openDeps(cfg)
// Inference has to be made by the client: the free tier leases its session
// to the running client and answers our own requests with 403 FreeTierError.
// So when a client server is up, that is the upstream. Lanes are then only
// needed for the model catalog, and the client brings its own egress.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ca, caErr := clientapi.Discover()
useClient := caErr == nil && ca.Ready(ctx) == nil
if hasFlag(args, "--no-client") {
useClient = false
}
switch {
case useClient:
fmt.Printf("inference: opencode client at %s (the free-tier session lives there)\n", ca.BaseURL)
case hasFlag(args, "--client"):
fmt.Fprintln(os.Stderr, "no opencode client server to serve free models:", caErr)
os.Exit(1)
case hasFlag(args, "--no-client"):
fmt.Fprintln(os.Stderr, "inference: replay upstream only (--no-client)")
default:
fmt.Fprintf(os.Stderr, "inference: replay upstream only -- the free tier answers it with 403 (%v)\n", caErr)
}

// tor-only: the replay path never dials direct. without lanes there is no
// service to serve, and no live catalog to advertise.
if !useClient {
if len(cfg.Socks) == 0 {
tor.Ensure(m, cfg.DataDir, 52001, 52301, 90*time.Second)
// A 429 rotates the lane: respawn its tor in the background so the
// next request lands on a fresh exit.
m.OnRotate = func(l *lanes.Lane) {
tor.Respawn(m, l, cfg.DataDir, 52001, 52301, 90*time.Second)
}
}
if n := healthySocks(m); n == 0 {
fmt.Fprintln(os.Stderr, "no tor lanes up (tor binary missing or bootstrap failed) -- refusing to serve direct")
os.Exit(1)
}
} else if len(cfg.Socks) == 0 {
fmt.Fprintln(os.Stderr, "note: no lanes configured -- /v1/models will use the embedded list")
}
up := upstream.NewClient(m)
up.BaseURL = cfg.BaseURL
up.WaitBudget = time.Duration(cfg.WaitBudgetS * float64(time.Second))
Expand All @@ -138,13 +242,31 @@ func cmdServe(args []string) {
}
srv := server.New(ks, m, up, cfg.DataDir)
srv.RequireKey = cfg.RequireKey
if useClient {
srv.ClientAPI = ca
// The plugin reads a spool directory, so a moved data dir moves both
// ends: no env var to forget.
srv.BridgeDir = bridge.Dir(cfg.DataDir)
bridge.Sweep(srv.BridgeDir, time.Hour)
}
fmt.Printf("lanvello %s listening on http://%s lanes=%d tor-only, free, no login\n", version, cfg.Listen, cfg.Lanes)
if err := http.ListenAndServe(cfg.Listen, srv.Handler()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

// hasFlag reports whether a bare switch (--client, --no-client) is present:
// they carry no value, so they are read straight off argv.
func hasFlag(args []string, name string) bool {
for _, a := range args {
if a == name || strings.HasPrefix(a, name+"=") {
return true
}
}
return false
}

// baseFlags lifts the flags baseConfig understands (--config,
// --data-dir, --listen, --lanes) out of a subcommand's argument
// list, dropping flags that belong to the subcommand itself
Expand Down
14 changes: 14 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# examples

## lanvello-bridge.ts

the client-side half of the `serve` bridge: copy it into
`~/.config/opencode/plugins/` and the caller's own system prompt, history and
tool schemas travel into the client's session, from the spool `serve` writes
under `<dataDir>/bridge/`. the caller's tools are injected into the client's
tool set — replacing the client's own, so a tool-less api request runs nothing
in the client's directory — and a call the model makes is recorded for the
gateway and blocked inside the client instead of being executed there: the api
caller gets it back as an openai `tool_calls` turn and runs it itself.

the spool directory is `$LANVELLO_BRIDGE_DIR`, defaulting to the gateway's own
`<dataDir>/bridge`, so a moved data dir moves both ends.

## lanvello-ext.mjs

pi-family provider extension (omp, prime-agent) pointing at a local
Expand Down
Loading
Loading