Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lingling

What even is this?

You know how the good AI models cost money, and the free ones are either useless or throttle you after three messages? OpenCode quietly gives away a handful of genuinely good models for free — no signup, no card, nothing. The only catch is they slow you down after a few requests, counting by your IP address.

Lingling is the little piece in the middle that fixes that. You point your editor at it — Cline, Codex, Claude Code, whatever you already use — and it passes your requests through to those free models, quietly spreading them across a pool of free network exits so you stop hitting the wall. That's the whole idea. Free models, in the editor you already have, without the throttling getting in your way.

It runs on your own computer. Nothing is uploaded anywhere, nothing calls home, there's no account. You start it, you point your editor at localhost, you're done.

If you just want to use it, jump to Getting started. The rest of this page gets more technical as you scroll — that's on purpose. Read as far as you care to.


"Wait, is this even allowed?"

Fair question, and the honest answer is yes. Here's the reasoning, not just the reassurance:

These models are already free and already keyless. You can call them right now with a single curl command and no credentials — that's how OpenCode published them, not a crack or a workaround. Lingling makes the exact same request you could type by hand.

It literally cannot touch the paid models. There's a check in the code that fails closed — a model is served only if OpenCode itself marks it free. Everything else is refused before a request is even built. Right now that's 7 served out of 60, and there's a test that breaks the build if that guard ever slips. No borrowed keys, no cracked accounts, no pretending to be a paying customer.

The IP rotation, said plainly. OpenCode limits the free tier by IP address. Lingling rotates through free Cloudflare WARP exits — a public service you sign up for yourself — so one throttled IP doesn't stall you. I won't dress it up: that's a rate-limit-softener. It doesn't unlock anything paid and there's no login to bypass, because the free tier has none. Whether that sits right with you is your call. Don't want it? Leave the pool empty and Lingling connects directly. That's the default.

It's yours alone. Runs on 127.0.0.1, everything stays local, nothing is hosted. The LICENSE forbids selling it.

If you want the paid models, buy them from OpenCode — that's what their key is for. Lingling is for the free ones.


Getting started

You need Python 3.11+ and about two minutes.

cd backend
pip install -r requirements.txt
python app.py

That's it. Open http://127.0.0.1:8000 in a browser and you'll get a dashboard showing which models are available and what your requests are doing.

On Windows you can double-click backend/start.bat instead — it does the same thing and also sets up the WARP proxy pool for you (the first run downloads two small tools and takes a minute or two; later runs are instant).

By default Lingling asks for an API key. Open the dashboard, go to Keys, click Create, and paste the ll_... token into your editor. If you'd rather skip that on your own machine, start it with LINGLING_REQUIRE_KEY=0.


How it works

Your editor  ──►  Lingling  ──►  OpenCode's free models
                     │
                     ├─ picks a model (you name one, or let it choose)
                     └─ rotates which IP the request goes out from

You either name a model directly, or send the special id lingling-auto and let Lingling pick. Auto-pick works by asking one small free model to read your request and choose the best model for it from the live list. If that fails or comes back with nonsense, a simple rule picks instead — so a request never gets dropped just because the chooser had an off moment.

Which models you get

Lingling asks OpenCode for its model list on every refresh and serves whatever is free at that moment — if OpenCode publishes a new free model tomorrow, it shows up on its own, no update needed. These are the seven available as of July 2026; the Catalog tab in the dashboard is always the live answer:

Model Good at Context
deepseek-v4-flash-free coding, long documents, deep reasoning 1M
north-mini-code-free code generation and refactoring 256K
big-pickle careful step-by-step reasoning and tool use 200K
mimo-v2.5-free images — the only free model that can see 128K
nemotron-3-ultra-free maths, planning, multi-step reasoning 128K
ling-3.0-flash-free fast general chat and light coding 128K
laguna-s-2.1-free everyday questions and writing 128K

Connecting your editor

Cline, or anything OpenAI-compatible

Setting Value
Provider type OpenAI Compatible
Base URL http://127.0.0.1:8000/v1
API key your ll_... token from the dashboard
Model any from the table above, or lingling-auto

Codex

Codex needs a little more, because recent versions dropped support for the older API style. Put this in ~/.codex/config.toml (on Windows: C:\Users\YOU\.codex\config.toml):

model_provider = "lingling"
model = "lingling-auto"

[model_providers.lingling]
name = "Lingling"
base_url = "http://127.0.0.1:8000/v1"
wire_api = "responses"
env_key = "LINGLING_API_KEY"

Then set your key in the terminal before running codex:

set LINGLING_API_KEY=ll_your_token_here

The wire_api = "responses" line matters — Codex 0.144+ only speaks the newer Responses format, and Lingling translates between the two for you. Leave out env_key entirely if you're running with LINGLING_REQUIRE_KEY=0.

Claude Code

Claude Code speaks Anthropic's own API rather than OpenAI's, so Lingling has a separate endpoint for it. The easiest way in is the setup window — double-click setup_claude_code.py at the repo root:

Point Claude Code at Lingling
  Gateway         http://127.0.0.1:8000
  Starting model  [ deepseek-v4-flash-free  v ]
  API key         (fetched from the gateway automatically)
                                     [Refresh] [Apply] [Close]

It reads the model list from your running gateway, mints or reuses an API key so you never copy one by hand, and writes ~/.claude/settings.json. It also strips any leftover provider block from a previous gateway — that key is not in Anthropic's settings reference, and it silently outranks everything documented, which is the usual reason "I set the variables and nothing changed".

Then just run claude. No environment variables.

If you would rather not use a window:

python setup_claude_code.py                                   :: lists models
python setup_claude_code.py --model deepseek-v4-flash-free

Or configure it by hand. Note the base URL has no /v1 — Claude Code appends that itself:

set ANTHROPIC_BASE_URL=http://127.0.0.1:8000
set ANTHROPIC_AUTH_TOKEN=ll_your_token_here

Model and thinking depth stay in the terminal. /model and /effort change them per session, and Lingling reads the depth off every single request. That is why the setup window has no depth selector and never writes ANTHROPIC_MODEL: Anthropic's precedence rule is that the environment variable overrides the model setting, so writing it would make /model a no-op — the picker would show your choice while every request carried the pinned one.

Leave the starting model unset and Claude Code asks for its usual claude-sonnet-... names, which Lingling maps onto free models by size class: haiku gets a fast one, sonnet the general-purpose default, opus the deepest thinker available. Naming a real free model pins it instead.

Claude Code also makes small background calls for session titles and conversation summaries, on whatever model its haiku alias resolves to. Those are served the same way, so nothing 404s while you work.

Thinking depth translates. Whether Claude Code sends the modern output_config.effort (low/medium/high/xhigh/max) or the older thinking.budget_tokens, Lingling ranks it and clamps it to what your chosen model actually publishes — see the effort section below for what that means per model.

One thing to expect from the free tier: on a hard prompt at max effort, a model can spend its entire token budget thinking and never answer. Lingling reports that plainly rather than showing you its scratch work, with stop_reason: max_tokens so the agent can retry. If you see it, /effort medium usually fixes it.


Reasoning effort — "how hard should it think?"

Most editors let you dial how much the model thinks before answering. Two things make this messy: every editor uses different words for it, and only some OpenCode models actually have the control at all.

Lingling reads each model's real capabilities from models.dev — the same data OpenCode's own CLI shows under /variants — and translates your editor's word into something that model honours.

Which models can be dialled

Model Levels it actually supports
deepseek-v4-flash-free high, max
ling-3.0-flash-free low, medium, high
laguna-s-2.1-free low, medium, high
north-mini-code-free none, high
mimo-v2.5-free none — always runs on its default
nemotron-3-ultra-free none — always runs on its default
big-pickle none — always runs on its default

This list is read live, not hardcoded. If OpenCode gives a model new levels tomorrow, Lingling picks them up on its next refresh.


The dashboard

Open http://127.0.0.1:8000 and you get five views down the left rail: Catalog (which models you can reach), Console (chat with them right there), Ledger (every request), Keys, and Egress (the WARP pool).

The Ledger is the one worth playing with. Click Arrange and every block becomes adjustable:

  • Drag the grip (or use arrow keys) to reorder blocks
  • The 3 / 4 / 6 / 8 / 12 buttons set how many of the 12 columns a block takes
  • hide tucks a block away; it comes back from the tray at the bottom
  • Charts with more than one useful shape get a style picker:
Block Styles
Activity · last hour area · line · bars
Latency spread bars · curve · cumulative
Share of traffic bars · donut · stacked
Outcomes bars · donut · stacked

These are different readings of the data, not skins. cumulative on latency answers "what share of responses finish within this budget", which the histogram can't tell you. Totals and Request log have no style picker — a number grid and a table have no honest second form, and inventing one would just be decoration.

Everything you change is saved in your browser and comes back on reload. Reset layout puts it all back.


How your setting gets translated

Editors speak different vocabularies:

Codex        none  minimal  low  medium  high  xhigh  max  ultra
Claude Code                 low  medium  high  xhigh  max  ultracode

A word on its own means nothing across a boundary — ultra is Codex's ceiling, ultracode is Claude Code's, and neither exists in OpenCode's vocabulary. So each label is placed on a shared 0–1 scale and the nearest level the model really supports wins:

You ask for On deepseek you get On ling you get On mimo you get
minimal high low nothing sent
medium high medium nothing sent
max max high nothing sent
ultra (Codex) max high nothing sent
ultracode (Claude Code) high high nothing sent

Two rules behind that table. When you ask for less than a model's floor, you get its floor — deepseek genuinely cannot think less than high, so that is not a fudge, it is the weakest setting that exists. When two levels are equally close, the weaker one wins, so a translation never quietly spends more thinking than you asked for.

The three models with no dial

mimo, nemotron and big-pickle expose no effort control. Whatever you set, they run on their own default — and Lingling sends no effort parameter at all rather than sending one for show. That matters because OpenCode returns a normal success response for a value a model ignores, so a parameter sent to these models would look like it worked while changing nothing. If you set max and get a reply in five seconds, this is why.

deepseek-v4-flash-free additionally has an on/off thinking toggle separate from its two depth levels. Lingling reads it but does not drive it yet.

If your editor sends nothing

Cline usually sends no effort setting. In that case Lingling sends none either — the model's own default applies. There is deliberately no LINGLING_DEFAULT_EFFORT: guessing on your behalf would be editorialising on a request the gateway is supposed to forward.

Codex: one setup step, then the dial works

Effort works in Codex too, but it needs one thing done first. Codex only sends the effort field for models listed in its own catalog, and a third-party model isn't in there — so until you give it a catalog, model_reasoning_effort is read, displayed in Codex's UI, and then dropped before the request leaves.

Lingling generates that catalog for you:

cd backend
python tools\codex_catalog.py

That writes ~/.codex/lingling_models.json and prints the line to add to ~/.codex/config.toml. It goes at the top, above the first [section] header — TOML puts anything after a header inside that section, and Codex reads this key at top level only:

model_catalog_json = "C:/Users/YOU/.codex/lingling_models.json"

Check it took with codex debug models — it should list Lingling's models instead of OpenAI's. After that, /model in Codex offers each model's real levels (high/max on deepseek, low/medium/high on ling and laguna), and whatever you pick arrives and gets translated exactly like any other editor's.

Re-run the generator after upgrading Codex or when OpenCode publishes new free models — it reads both live, so it can't go stale on its own. Models with no effort control are deliberately left out; nothing about them changes.

Cline and direct API calls need none of this — they send the field as-is.

Claude Code: no setup step needed

Claude Code sends its depth on every request, so there is nothing to configure. It uses two different fields depending on version, and both are read:

output_config.effort            low  medium  high  xhigh  max
thinking.budget_tokens          a token count, bucketed onto the same ladder
thinking.type = "disabled"      an explicit "don't think"

Where both appear, output_config.effort wins — that is the field Anthropic's own migration guide moves to, and thinking: {"type": "adaptive"} alongside it is a mode rather than a depth.

A token budget becomes a rung by size, with the boundaries sitting between Claude Code's own presets (roughly 4k for think, 10k for think harder, 32k for ultrathink) so a preset lands unambiguously. And thinking: disabled maps to none, which is different from sending nothing: a model publishing an off rung gets told to stop, instead of being left at its default.

After that it goes through the same translation as every other editor — ranked, clamped to what the routed model publishes, dropped entirely for models with no dial.


Under the hood

Everything below is for people who want to read or change the code. You don't need any of it to use Lingling.

Key Components

Component File Purpose
FastAPI app backend/app.py Server: routes, auth middleware, streaming pipeline
Auth backend/core/auth.py Session cookies for the dashboard, API keys for clients
Stream guard backend/routing/stream_guard.py One retry when a stream dies mid-flight
Stream watchdog backend/routing/stream_idle.py Cuts off a stream that stops speaking without closing
Egress parking backend/routing/parking.py Waits for a cooling exit instead of answering 503
Router backend/routing/dispatcher.py Picks a model for lingling-auto requests, with a deterministic fallback
Effort backend/routing/effort.py Translates each harness's thinking-depth label onto OpenCode's ladder
Codex catalog backend/models/codex_catalog.py Builds the catalog file Codex needs before it will send an effort field
Claude Code backend/claudecode/ Anthropic Messages translation, kept apart from the Codex bridge on purpose
Executor backend/routing/executor.py Calls providers with failover logic
WARP Manager backend/warp/manager.py Manages Cloudflare WARP identities -> SOCKS5 proxies
Proxy Pool backend/providers/proxy_pool.py Rotating egress proxy pool
Provider backend/providers/opencode.py OpenCode free-tier provider implementation
Usage backend/usage/store.py SQLite usage ledger (two-phase for streams)
Frontend frontend/index.html Single-page dashboard
Tokens frontend/tokens.css The design system: every colour/font/space value

Endpoints

Method Path Auth Purpose
GET / open Dashboard; issues the session cookie
GET /api/health open Liveness + provider/catalog/auth summary
GET /v1/models open OpenAI-compatible model list (for client pickers)
POST /v1/chat/completions gated Main router -- runs a prompt through the selected model
POST /v1/responses gated Responses bridge for Codex 0.144+ (wire_api = "responses")
POST /v1/messages gated Anthropic Messages bridge for Claude Code
GET /api/models gated Multi-model entry + free model list
GET /api/usage gated Summary + recent feed + daily + live buckets
GET /api/usage/since/{id} gated Rows newer than {id} + refreshed aggregates
DELETE /api/usage gated Wipe the ledger (irreversible)
GET/POST /api/keys gated List / create user API keys
DELETE /api/keys/{kid} gated Revoke a key
GET/POST /api/proxies gated Proxy pool status / add a proxy
GET /api/warp gated WARP manager status
POST /api/warp/setup gated Register WARP identities
POST /api/warp/start gated Start WARP instances
POST /api/warp/refresh gated Rebuild the identity pool (destructive)
GET /api/warp/health gated Health check WARP proxies

Auth

Two callers, two mechanisms -- see backend/core/auth.py:

  • The dashboard gets a signed, HttpOnly, SameSite=strict session cookie when it loads /. The signing secret is per-process, so a restart invalidates outstanding sessions.
  • API clients send Authorization: Bearer ll_... (or x-api-key: ll_...).

LINGLING_REQUIRE_KEY=1 is the default; set it to 0 for a fully open local gateway. Everything under /api/ and /v1/ is gated except the three open paths in the table above.

CORS is an explicit allow-list (loopback dashboard ports plus LINGLING_ALLOWED_ORIGINS), not *.


Response Handling

Lingling does not rewrite, re-space, or restructure upstream output. OpenCode already handles BPE decoding, reasoning fields, and SSE streaming. We forward the raw response body (JSON or SSE) and only append routing metadata in the lingling envelope.

Two deliberate exceptions sit at the Lingling edge:

  • _harvest_stream_usage() reads each SSE line in passing to capture token counts, because a streamed request otherwise records zero usage. The bytes forwarded to chat-completions clients are untouched.
  • /v1/responses translates Responses request/stream shapes to and from chat completions for Codex. It is stateless: send complete context; Lingling does not store or resolve previous_response_id.

Reasoning effort — implementation notes

The user-facing table is above. The mechanics:

Effort values come from models.dev's per-model reasoning_options, read via metadata.reasoning_effort_values(). They are not hardcoded and cannot be probed: OpenCode returns HTTP 200 for a value a model does not implement, silently ignoring it, so a probe cannot tell "honoured" from "swallowed". An earlier version of this module built its own table by probing and was wrong for six of the seven free models — it also claimed 1M context for deepseek (really 200K) and missed that nemotron has 1M (it was recorded as 128K). Context limits now come from the same source.

Translation is by rank, not by name. routing/effort.py maps every harness word onto a 0–1 scale and picks the nearest value the target model publishes. Rank is necessary because the published sets are sparse and not slices of one common ladder — ["high", "max"] and ["none", "high"] have no shared ordering to clamp along. Ties resolve to the weaker value.

Empty published list means the model has no effort control, and the parameter is dropped. clamp also skips any published value this module has no rank for, so one unfamiliar entry from a third-party feed cannot disable effort for a whole model, and matching is case/whitespace-insensitive while the string sent back is the provider's own spelling.

Codex nests it as reasoning: {"effort": …}; the chat path reads a flat reasoning_effort. Both land in _resolve_effort(), which runs after routing — with lingling-auto the target model is not known until the dispatcher has run, and the legal set depends on the target. It is idempotent, so the streaming retry path can re-enter it safely, and it re-resolves correctly if failover moves a request to a different model.

reasoning_toggle (deepseek's on/off "Default") is parsed and exposed on the model's capabilities but not yet driven by any request field.

Getting Codex to send the field at all

Codex decides whether to put reasoning on the wire by looking the model up in its own catalog; an unknown model gets "reasoning": null regardless of config. Confirmed by pointing Codex at a capture proxy: same request, same model_reasoning_effort=high, null with the stock catalog and {"effort": "high", "summary": "auto"} once the model is declared.

models/codex_catalog.py builds that declaration and tools/codex_catalog.py writes it. Three things about the schema are worth knowing before touching it:

  • Entries are clones of a real Codex model. One of the 35 fields is base_instructions — the entire Codex agent prompt, 11–21 KB. A hand-written entry with a short prompt parses fine and quietly turns Codex into a much dumber agent, because that string is the harness. The generator therefore dumps a real model via codex debug models (UTF-8 BOM, read with utf-8-sig) and overrides only identity, levels and limits. Unknown fields ride along untouched, so a Codex upgrade that adds one doesn't invalidate the file.
  • The template must be a classic-Responses model. Codex's newest models set use_responses_lite / tool_mode = "code_mode_only", and cloning one changes what Codex sends: no instructions, no tools array, tools moved into an additional_tools input item wrapping a JavaScript exec tool. The bridge reads instructions and tools, so that variant arrives with no system prompt and no tools. pick_template() skips them.
  • Overrides never write null. The parser dumps null for absent optional fields but rejects it on input for some of them — web_search_tool_type: null fails with "expected value", reproducibly.

Also note supports_reasoning_summaries: false suppresses reasoning entirely even for a declared model, so entries force it true; and Codex sends whatever effort its config holds even when the value isn't in the declared list, which is why _resolve_effort() still clamps. model_catalog_json only accepts a filesystem path — a URL is read as one and fails — so this is a generated file rather than a Lingling endpoint.

End-to-end, deepseek-v4-flash-free answering the same prompt through Codex at its two levels: median 1056 reasoning tokens at high against 1667 at max (5 runs each). Per-run counts overlap, which is why medians and not a single pair.


Usage ledger

Streaming is logged in two phases:

  1. UsageStore.log() inserts at first-chunk time and returns the row id, so a stream that dies mid-flight still leaves a record.
  2. UsageStore.finalize() fills in tokens and the true total duration once the upstream emits its terminal usage chunk.

buckets() gives minute-resolution buckets over the last hour -- this is what the dashboard's live chart reads. daily() can't show live traffic (one day-wide bucket hides everything until tomorrow). Rows older than LINGLING_USAGE_RETENTION_DAYS (default 90) are pruned on startup.


Tests

# Backend: 112 tests. Two files -- routing/WARP/parking, and Claude Code.
cd backend && python -m pytest tests/ -q

# Or run the routing suite directly, which prints a per-test summary:
cd backend && python tests/test_routing.py

# Frontend: 52 assertions driven through headless Chrome over CDP.
# Needs the gateway running plus Chrome with a debug port:
chrome --headless=new --remote-debugging-port=9222 --user-data-dir=%TEMP%\cdp
node frontend/tests/smoke.mjs http://127.0.0.1:8000 http://127.0.0.1:9222

Most backend tests are hermetic — they stand a fake provider in front of the real FastAPI app, so a request travels the whole path (handler, bridge, routing, effort resolution, executor) and the assertions are about what actually reached the model. A handful are live and hit the real free tier; they skip themselves when it is unavailable.

The frontend suite asserts every view renders against live data, the board's drag/resize/hide/persist behaviour and keyboard reordering, chart survival across repaints, the Stop control and vision guard, and that no console error, exception or 4xx/5xx occurred during the run.

The frontend suite needs a ledger with history in it. Three chart assertions fail against an empty database — index.html deliberately draws no canvas when there is nothing to plot, so a fresh install reports 3 false failures. Send a few requests first, or point it at a gateway that has been used.


Config (backend/core/config.py)

Variable Default Description
LINGLING_HOST / LINGLING_PORT 127.0.0.1 / 8000 Bind address for python app.py
LINGLING_REQUIRE_KEY 1 Auth gate
LINGLING_SESSION_TTL 43200 (12 h) Dashboard session lifetime
LINGLING_ALLOWED_ORIGINS (empty) Extra CORS origins, comma-separated
LINGLING_OPENCODE_KEYS (empty) OpenCode keys via env instead of accounts.json
LINGLING_MULTIMODEL_ID lingling-auto Virtual model id that triggers auto-selection
LINGLING_DISPATCHER_MODEL deepseek-v4-flash-free Free model asked to choose when auto-selecting
LINGLING_WARP_COUNT 10 Number of WARP identities
LINGLING_USAGE_RETENTION_DAYS 90 Ledger pruning window; 0 disables
LINGLING_STREAM_RECOVERY 1 Retry once when a stream dies mid-flight
LINGLING_STREAM_IDLE_TIMEOUT 90 Treat an open-but-silent stream as broken after this many seconds; 0 disables
LINGLING_EGRESS_WAIT_BUDGET 120 Longest a request waits for a cooling exit before failing (s); 0 restores the old 503
LINGLING_DATA_DIR ./data Data directory
LINGLING_STREAM_FIRST_TOKEN_TIMEOUT 45 Time-to-first-token budget (s)
LINGLING_DISPATCH_TIMEOUT 60 Timeout for the auto-select model (s)
LINGLING_REQUEST_TIMEOUT 120 Per-request upstream timeout (s)
LINGLING_CATALOG_TTL 600 Model-list cache lifetime (s)
LINGLING_CATALOG_RETRY 30 Backoff before retrying an empty model list (s)
LINGLING_FAST_MODELS_DIRECT 0 1 lets the fast models bypass the egress pool
LINGLING_PROXY_STICKY 0 1 pins a conversation to one exit IP (see below)

Egress selection is least-loaded-first: every request goes out through whichever WARP identity has done the least work in the last few minutes, so the ten IPs burn their per-IP quotas evenly.

LINGLING_PROXY_STICKY=1 instead keeps one conversation on one exit IP. Leave it off unless an upstream genuinely needs that affinity — a coding agent sends a single session id for its whole run, so pinning makes one identity absorb the entire session's quota while the other nine idle.


Secrets

.gitignore covers backend/accounts.json, backend/proxies.json and backend/data/ (issued keys, usage DB, WARP private keys). Provide your OpenCode key via LINGLING_OPENCODE_KEYS (comma-separated) so no secret ever touches the working tree, or copy backend/accounts.example.json to backend/accounts.json and fill it in locally. The free tier is keyless, so a key is optional.


Known Edge Cases / Limitations

  1. Proxy first-token timeout -- when a local WARP SOCKS proxy is unreachable, the first-chunk wait may consume the configured timeout before failing over.
  2. A recovered answer differs from the partial one -- mid-stream recovery re-requests rather than resuming, because models are not deterministic. The client is told to discard the partial via a lingling_reset frame and the dashboard labels the turn recovered; a third-party client that ignores the marker would render the answer twice, so recovery is opt-out per request ({"lingling_recover": false}) and globally (LINGLING_STREAM_RECOVERY=0).
  3. A silent stream is cut off, not waited on -- an upstream that stops speaking while the socket stays open is treated as broken after LINGLING_STREAM_IDLE_TIMEOUT (90 s) and handed to the normal retry. Before this, one measured request sat 885 seconds with zero tokens before giving up, which is indistinguishable from a hang. A thinking model streams reasoning continuously, so a long think keeps the clock reset; only a true stall trips it.
  4. Only one retry -- two consecutive failures end the stream and log stream_broken.
  5. A parked request looks slow, not broken -- when every exit is cooling, the request waits for the next one instead of returning 503 (see below). Clients with their own short read timeout may give up before Lingling answers; lower LINGLING_EGRESS_WAIT_BUDGET to fail faster.

Waiting out a burned pool

OpenCode meters its free tier per IP, so when all ten WARP exits are in cooldown at once the pool is not broken — it is busy, and the first exit is usually back within seconds. Returning HTTP 503 there was the worst possible answer: Cline and Codex treat it as fatal and abandon the whole task, so the human loses the accumulated context of a long run and restarts from scratch, while the pool recovers a minute later and sits idle.

Lingling now holds the request instead. ProxyPool.time_until_available() reports how long the soonest exit still needs; the request waits that long and goes out again. A turn that takes seventy seconds is an annoyance, a turn that fails is a dead session, and nobody trades those the other way round.

The wait is deliberately narrow. It only happens when every exit is cooling, which is the one case where trying again immediately cannot help. A healthy pool, an empty pool, and any non-retryable status all fail exactly as they did before — including the fall-back-to-another-model path. And the wait is awaited on the event loop, never slept inside a worker thread, so a parked request cannot starve the threadpool that the retry itself needs.

The same wait guards mid-stream recovery. When a stream dies after bytes are already on the wire, stream_guard gets exactly one retry; reopening it while every exit was cooling spent that attempt on an IP that had to refuse, and the user lost the rest of the answer. Now the retry waits for capacity first. Since HTTP 200 was sent long ago, that wait cannot be silent — it emits SSE comment frames (: lingling is holding…) about once a second, which clients ignore and which never reach usage harvesting. The wait is sliced rather than taken in one sleep so the worker thread driving the response is handed back between frames.

LINGLING_EGRESS_WAIT_BUDGET caps it (120 s by default). If the soonest exit needs longer than the budget, the request fails immediately rather than stranding the client; 0 disables parking entirely.


Streaming recovery

backend/routing/stream_guard.py wraps the upstream generator. If a stream ends without the model reporting finish_reason, a replacement stream is opened through the executor -- landing on a fresh exit IP -- and the client receives:

data: {"lingling_reset": {"reason": "...", "attempt": 2}, "choices": []}

before the new answer. Ledger status becomes ok_recovered; a doubly-failed stream becomes stream_broken. A client hanging up raises GeneratorExit, which is not caught, so a cancelled request never burns a retry.


Contributing / roadmap

Lingling is actively developed and future updates land here as commits — the early history is sparse because the first few commits were large drops, but changes from here on are committed as they're made, so the git log is the real changelog.

Recent work:

  • Codex support — a /v1/responses bridge, since Codex 0.144+ dropped the older chat-completions wire format
  • Reasoning effort — translates each editor's thinking-depth vocabulary onto what OpenCode actually accepts
  • Codex-native efforttools/codex_catalog.py generates the catalog file Codex needs before it will send the effort field for a third-party model
  • Claude Code — a /v1/messages endpoint speaking Anthropic's format, so it points at Lingling directly, with its thinking depth translated the same way

Planned, roughly in order:

  • More free providers — the provider layer is already an interface (providers/base.py); a second free gateway is one subclass, and the catalog merges models that appear in more than one so they can fail over between them

Issues and pull requests are welcome. If you're adding a provider, start at providers/base.py and copy the shape of providers/opencode.py. Run python tests/test_routing.py before opening a PR — it hits the live free tier, so it catches upstream changes a mocked suite would miss.


License

See LICENSE. Free to use and run; may not be sold or redistributed in modified form. Copyright (c) 2026 huranth.

About

Local OpenAI-compatible routing proxy for free AI models, with rotating Cloudflare WARP egress

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages