A Go proxy that makes the Cline API use a chosen inference provider for a given model, and converts Cline's data-wrapped JSON completions into the OpenAI response format. Rules match model IDs with or without the cline-pass/ prefix; whether routing takes effect depends on the model honoring Cline's routing fields.
It builds into one executable, uses only the Go standard library, and supports streaming responses, configuration reloads, and an optional admin API.
OpenAI-compatible client → cline-pin-proxy → Cline API → inference provider
The image needs Docker with the Compose v2 plugin (docker compose version) — no source checkout and no Go toolchain. It supports linux/amd64 and linux/arm64. The commands below use Bash.
1. Save this as docker-compose.yaml.
services:
cline-pin-proxy:
image: ghcr.io/thinker-joe/cline-pin-proxy:latest
restart: unless-stopped
ports:
- "127.0.0.1:8787:8787"
# environment:
# CLINE_PIN_API_KEY: "sk-..." # optional: one shared upstream key for all requestsNo configuration file is required: the default rules apply, and each client sends its own Cline API key. The image sets CLINE_PIN_LISTEN=0.0.0.0:8787 so that port publishing works, and includes a healthcheck.
2. Start and check it.
docker compose up -d --wait
curl -fsS http://127.0.0.1:8787/healthz--wait returns once the container healthcheck passes. The endpoint returns {"status":"ok"} and checks the proxy process, not upstream availability. If startup fails, run docker compose logs --tail=100 cline-pin-proxy.
3. Point a client at it.
Any client that lets you set an OpenAI-compatible base URL can use the proxy: replace Cline's API base URL https://api.cline.bot/api/v1 with the proxy URL and change nothing else — the API key and model names stay as they are.
| Setting | Value |
|---|---|
| Base URL | http://127.0.0.1:8787/v1 (replacing https://api.cline.bot/api/v1) |
| API key | Your Cline API key; any non-empty value when CLINE_PIN_API_KEY is set |
| Model | cline-pass/deepseek-v4.1-flash |
Model IDs are preserved and models without a matching rule are forwarded unchanged, so existing model names keep working. Port 8787 is published only on the Docker host's loopback interface. For a client in another container, connect both to a shared Docker network and use http://cline-pin-proxy:8787/v1; see sub2api integration.
Test request:
export CLINE_KEY='<your Cline API key>'
curl -i http://127.0.0.1:8787/v1/chat/completions \
-H "Authorization: Bearer $CLINE_KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"cline-pass/deepseek-v4.1-flash","messages":[{"role":"user","content":"Reply with OK"}],"max_tokens":512}'By default the proxy forwards the client's Authorization header. A fixed api_key/CLINE_PIN_API_KEY replaces client credentials; it does not add authentication to the proxy's own API routes.
Managing the container. Run these from the directory containing your Compose file:
| Task | Command |
|---|---|
| View status | docker compose ps |
| Follow logs | docker compose logs -f --tail=100 cline-pin-proxy |
| Stop / start | docker compose stop / docker compose start |
| Remove container and Compose network | docker compose down |
To update the image, pull it and recreate the service. This briefly interrupts service:
docker compose pull
docker compose up -d --waitdocker compose restart does not switch to a newly pulled image. To build from local source instead, use a repository checkout, uncomment build: . in its docker-compose.yml, and run docker compose up -d --build --wait.
Add this only for custom rules, the admin API, or settings you want to keep across container recreation. The repository's docker-compose.yml is the hardened version (data mount, read-only root filesystem, log rotation); its mount and command are:
volumes:
- ./data:/etc/cline-pin-proxy
command: ["serve", "-config", "/etc/cline-pin-proxy/config.json"]mkdir -p data && cp config.example.json data/config.json
docker compose up -d --waitconfig.example.json matches the built-in defaults. Omitting rules keeps the default rules; other omitted fields use application defaults. The file is polled, and most settings reload within five seconds (Reloading). The data/ directory survives docker compose down.
Validate the file and preview a rule using the running container:
docker compose exec cline-pin-proxy /usr/local/bin/cline-pin-proxy \
check -config /etc/cline-pin-proxy/config.json -model deepseek/deepseek-v4-flashDownload a binary from GitHub Releases, or build with Go 1.23 or later:
go build -o cline-pin-proxy ./cmd/cline-pin-proxy
./cline-pin-proxy serve -config config.json-config is explicit: the binary does not automatically load config.json from the working directory. Without it, configuration comes from defaults and environment variables.
Precedence is non-empty environment variables > configuration file > defaults. Whitespace-only environment values do not override the file. Command flags such as serve -listen and probe -api-key take precedence for that command.
| JSON field | Default | Environment variable |
|---|---|---|
listen |
127.0.0.1:8787 |
CLINE_PIN_LISTEN |
upstream |
https://api.cline.bot/api/v1 |
CLINE_PIN_UPSTREAM |
api_key |
Empty; forward client credentials | CLINE_PIN_API_KEY |
forward_headers |
["x-client-type"] |
CLINE_PIN_FORWARD_HEADERS |
probe_headers |
{"x-client-type":"cline-cli"} |
CLINE_PIN_PROBE_HEADERS |
max_body_bytes |
67108864 (64 MiB) |
CLINE_PIN_MAX_BODY_BYTES |
watch_seconds |
5; 0 disables polling |
CLINE_PIN_WATCH_SECONDS |
admin_token |
Empty; admin API disabled by default | CLINE_PIN_ADMIN_TOKEN |
admin_allow_unauthenticated |
false |
CLINE_PIN_ADMIN_ALLOW_UNAUTHENTICATED |
unwrap_data_envelope |
true |
CLINE_PIN_UNWRAP_DATA_ENVELOPE |
rules |
Default rules | CLINE_PIN_RULES |
CLINE_PIN_FORWARD_HEADERS takes comma-separated header names, CLINE_PIN_PROBE_HEADERS comma-separated name: value pairs, and CLINE_PIN_RULES a JSON array. CLINE_PIN_LOG_LEVEL sets debug, info, warn, or error and has no JSON equivalent.
upstream must be an HTTP(S) URL with a host and no query or fragment. The configuration must be a JSON object; null is not valid. Invalid rule JSON and invalid numeric or boolean environment values fail loading. The missing-file startup exception is recorded under known implementation limits.
The server polls the file's modification time at the configured interval. A valid update replaces the active configuration; an invalid or missing file keeps the last valid one, with repeated identical errors logged once and a recovery message once loading succeeds.
Editing a mounted file needs no container restart: the watcher reloads it in place. listen, watch_seconds, and the log level apply at startup only; everything else can reload, including the admin token. Environment overrides still win after a reload, so remove a field's override before managing it through the file, and recreate a container to change its environment. serve -config with a missing file starts on defaults and environment settings and waits for the file to appear; an existing invalid file prevents startup. check and probe require the file they name to exist.
Set admin_token to enable these endpoints, and authenticate with Authorization: Bearer <token> or X-Admin-Token: <token>.
| Method | Path | Behavior |
|---|---|---|
GET |
/admin/config |
Read the loaded configuration, omitting api_key and admin_token. |
GET |
/admin/rules |
Read {"rules":[...]}. |
PUT |
/admin/rules |
Replace rules in memory and attempt to save them to the file. |
POST |
/admin/probe |
Probe with {"model":"...","pipeline":"auto"}. |
POST |
/admin/reload |
Reload the file immediately. |
Without a token these routes return 404 unless admin_allow_unauthenticated is explicitly enabled; with a token set, missing or incorrect credentials return 401 even then. Allow unauthenticated access only in a trusted environment.
curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://127.0.0.1:8787/admin/rules > rules.json
# Edit rules.json, then submit the complete list.
curl -fsS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' --data-binary @rules.json \
http://127.0.0.1:8787/admin/rulesPUT accepts a bare array or {"rules":[...]} and rejects null, null elements, unknown rule fields, and trailing data; only [] clears the list. A successful response reports applied, persisted, and the current rules; if saving fails it also returns persist_error and hint, and unsaved rules can be replaced by a later reload or lost on restart. CLINE_PIN_RULES keeps overriding file rules, so remove that override before managing rules through the API and inspect the returned array.
Saving rewrites only the rules value, preserves other JSON values and existing file permission bits, and does not copy environment secrets into the file. Atomic replacement needs a writable directory; the container runs as UID/GID 65532, so on Linux use sudo chown -R 65532:65532 data. If atomic replacement is unavailable the proxy may write in place and logs a warning; in-place writes are not atomic, and disk-full or I/O errors do not trigger that fallback. POST /admin/probe uses the configured upstream key and probe headers; the admin token is not an upstream credential.
Rules match the request's model value; no namespace is required, and the proxy preserves the model ID. The default deepseek rule matches both cline-pass/deepseek-v4.1-flash and deepseek/deepseek-v4-flash, and the glm-5.3-flash rule also matches z-ai/glm-5.3-flash. These prefixes are part of the JSON value, not the HTTP path: all of them use POST /v1/chat/completions.
cline-pass/ is a routing prefix, not part of a model name: what follows it must be a model slug that exists in the catalogue, and the same slug can route differently with and without the prefix. As of 2026-09-20, cline-pass/deepseek-v4.1-flash defaulted to the DeepSeek provider while the bare deepseek/deepseek-v4.1-flash defaulted to Alibaba — so changing an ID between those forms changes routing even when pinning has no effect.
Cline uses two routing pipelines, each reading provider preferences from a different place:
| Pipeline | Backend | Routing fields | Response metadata |
|---|---|---|---|
planner |
Vercel AI Gateway | providerOptions.gateway |
choices[0].message.provider_metadata.gateway.routing |
direct |
OpenRouter | Top-level provider |
provider |
Cline decides the pipeline for each model. With pipeline: "auto" the proxy writes both sets of fields; a strict DeepSeek rule adds:
{
"providerOptions": {"gateway": {"only": ["deepseek"]}},
"provider": {"only": ["deepseek"]}
}Effective selection also needs account access to the model and gateway support for its routing fields; appearing in /v1/models does not establish that. Whether a model honors these fields is decided by Cline, varies per model — including between models on the same pipeline — and can change without notice. Successful injection therefore does not guarantee the requested provider was used. Confirm with the response metadata: choices[0].message.provider_metadata.gateway.routing.finalProvider on the planner pipeline, or top-level provider on the direct pipeline. When a filter is honored, planningReasoning contains Provider set restricted to: <slug> and fallbacksAvailable narrows.
As of 2026-09-20 the whole providerOptions.gateway block (only, order, sort) had no effect on deepseek/deepseek-v4.1-flash, while z-ai/glm-5.3 (planner), z-ai/glm-5.3-flash, and deepseek/deepseek-v4-flash (direct) still honored it. The same model did honor it earlier the same day, so treat these as dated observations and re-check before relying on a rule. cline-pass/deepseek-v4-pro still honors the filters, while cline-pass/deepseek-v4-flash was removed from the catalogue (404 on 2026-10-17), and the private channel behind cline-pass/kimi-k3 has ignored them since 2026-09-20. See the verification record.
On 2026-10-17 the cline-pass/ prefix changed routing for GLM 5.3: z-ai/glm-5.3 was still on the planner pipeline and honored the filters, while cline-pass/glm-5.3 was served by a private contributor pool and ignored them — so the default glm-5.3 rule has no effect on the prefixed ID. cline-pass/glm-5.3-flash stayed on the direct pipeline and still honors its rule. Some earlier cline-pass IDs are gone (cline-pass/deepseek-v4-flash now returns 404). Which cline-pass IDs exist, which pipeline they use, and whether they honor filters must be re-checked per ID; the dated matrix is in the verification record, and probe reports the outcome per model.
Rules use case-insensitive substring matching, and the first match wins.
| Model substring, in order | Provider | Mode |
|---|---|---|
deepseek |
deepseek |
strict |
glm-5.3-flash |
relace |
strict |
glm-5.3 |
friendli |
strict |
glm-5.3 also matches the flash model name, so the flash rule must stay first; the two GLM models have different provider lists, so a single broad glm rule can select an unsupported provider. The GLM defaults come from measurements on 2026-09-16: friendli returned the first stream bytes in 0.31–0.35 seconds for glm-5.3, and relace in 0.72–0.97 seconds for glm-5.3-flash, with four successful requests each. These are historical observations, not latency or quality guarantees; third-party providers may serve quantized models.
The first rule needs a caveat: as of 2026-09-20 it cannot enforce anything on deepseek/deepseek-v4.1-flash, so DeepSeek is used there because Cline's own default already selects it — not because the proxy pinned it. Both the bare and the cline-pass/ form of that ID ignore only/order/sort (2026-10-17: the actual provider was Alibaba in every run), and the planner pipeline has no ignore field, so there is no way to pin the main ID. To enforce the official provider, use the same-day snapshot alias deepseek/deepseek-v4.1-flash-1017, whose planner candidates do honor only: a strict deepseek rule pins it (finalProvider=deepseek, verified 2026-10-17). On that alias preferred/order is not honored, so the rule must be strict; the alias is a bare ID outside the subscription namespace, and it may disappear when the catalogue rotates. Provider IDs, filtering exceptions, and full measurements are in the verification notes (Chinese).
The third rule needs a similar caveat: as of 2026-10-17 it still enforces friendli on z-ai/glm-5.3, but cline-pass/glm-5.3 is now served by a private contributor pool that ignores the filter, so the rule only appears to work for that ID.
| Field | Values and behavior |
|---|---|
name |
Label used in logs and response headers. Defaults to rule-<index>. |
model |
Required model ID or substring to match. |
match |
contains (default), prefix, or exact. All are case-insensitive. |
pipeline |
auto (default) writes both pipelines; planner or direct writes only that pipeline. |
mode |
strict (default) writes only with the first provider. preferred writes order and allows fallback. exclude writes provider.ignore (direct pipeline only). |
upstreams |
Provider ID array, required for strict and preferred. strict uses only the first item; preferred requires at least two. Must be empty for exclude. |
exclude |
Provider IDs to deny, required for exclude and rejected otherwise. Direct pipeline only: pipeline: "planner" is refused, and auto therefore affects only the direct pipeline. |
sort |
Optional: cost, ttft, or tps. On the direct pipeline these map to price, latency, and throughput. |
On the selected pipelines, strict removes an existing order, and preferred removes existing only and allow_fallbacks; other provider options are preserved, and sort replaces the caller's value only when configured. Adding sort to a strict rule neither enables fallback nor removes only, so rules cannot express sorting alone. exclude takes the opposite approach: it replaces the caller's provider.ignore with the configured list and does not write or remove only/order, so a caller's own filter stays in force. A file's rules field replaces the entire default list; use "rules": [] to disable injection. config.example.json is a complete configuration matching the defaults.
A rule can also take the opposite approach — deny some providers and let the gateway choose among the rest. That deny list only exists on the direct pipeline:
{"provider": {"ignore": ["z-ai"]}}Measured on 2026-10-17: ignore does reach OpenRouter — a request whose only and ignore both named relace failed with All providers have been ignored — while writing exclude into providerOptions.gateway was dropped by the planner pipeline. That is why this mode has no planner equivalent rather than a silent no-op.
The two pipelines spell some providers differently, and a wrong spelling fails the request instead of being ignored (measured 2026-10-17: planner accepts zai and togetherai, direct accepts z-ai and together). When writing a rule with pipeline: "auto", put the provider ID as you know it: the proxy translates these measured pairs per pipeline before injection. Provider IDs outside that table are written unchanged, and the log and X-Cline-Pin-Upstreams show your configured values, not the translated ones.
Cline has been observed returning non-streaming completions wrapped like this, which clients that read top-level choices cannot parse:
{"data":{"choices":[{"message":{"role":"assistant","content":"OK"}}]},"success":true}By default the proxy returns the inner data object and sets X-Cline-Pin-Unwrapped: data-envelope. Unwrapping requires a JSON content type plus all of the following:
- The top level has no
choicesfield. datais an object.data.choicesis a non-empty array.
Other bodies are forwarded unchanged. JSON responses are buffered up to an 8 MiB limit, with one extra byte read to detect overflow; larger responses are forwarded unchanged and marked X-Cline-Pin-Unwrapped: skipped-too-large. SSE (text/event-stream) is forwarded and flushed as chunks arrive, without JSON parsing or waiting for the complete response. Set "unwrap_data_envelope": false to disable the conversion; it applies to JSON responses on all forwarded routes, whether or not a rule matched.
With CLINE_PIN_API_KEY set, list the providers the gateway reports:
./cline-pin-proxy probe -model cline-pass/deepseek-v4.1-flash
./cline-pin-proxy probe -model deepseek/deepseek-v4-flash -H 'x-client-type: cline-cli'The probe injects only: ["__probe__"] and parses the routing error. Models that honor the filter reject the request before inference; models that ignore it may generate a response and incur charges. The returned list can be incomplete, and a listed provider can still fail a real request. Use -pipeline planner or -pipeline direct to restrict injection; probes send x-client-type: cline-cli by default because some models return 403 without it. The printed suggested rule is an exact match on the first reported provider — review that choice, since replacing rules replaces all existing rules.
Every run reports an outcome, which separates the reasons a candidate list can be missing:
| Outcome | Meaning and next step |
|---|---|
routing-error |
The gateway rejected the probe upstream at the routing layer; the candidate list comes from the error body. |
filter-ignored |
The filter fields were ignored and the gateway actually ran the inference (possibly billed). No candidate list can be obtained; the provider that served this request (served) and the pipeline are read from response metadata, and they are an observation, not a candidate list. |
empty-content |
The provider answered with empty response content, which is also billed. Reaching the model means the filter was not honored — an honored filter rejects before inference. Raise the output budget for a real request, then read the routing metadata. |
auth |
401/403: identity or entitlement was rejected, so the run concludes nothing about routing. Check the key, the account's model plan, or a missing identity header. |
model-unavailable |
404 model not found: the model does not exist or is retired. |
rate-limited |
429: rate-limited upstream; retry later. |
POST /admin/probe returns the same outcome and served fields.
check validates configuration and previews a match without contacting Cline:
./cline-pin-proxy check -config config.json -model cline-pass/glm-5.3-flashRun ./cline-pin-proxy help for commands and ./cline-pin-proxy <command> -h for flags. CLI messages are currently in Chinese.
For a Cline Pass account configured as an OpenAI-compatible API-key account in sub2api, change its base URL from https://api.cline.bot/api/v1 to the proxy's /v1 URL.
- If both services run in containers, connect them to a shared Docker network and use
http://cline-pin-proxy:8787/v1. A container's127.0.0.1refers to itself. - Keep the Cline Pass key on the account unless the proxy has a fixed upstream key.
- If the account sets
x-client-type: cline-cli, the default proxy configuration forwards it. Normal proxy requests do not add that header themselves. - Requests to
/v1/responsesand other supported paths are forwarded without provider injection, allowing sub2api to check upstream protocol support.
| Header | Meaning |
|---|---|
X-Cline-Pin-Rule |
Rule applied to a chat completion request; none if no rule was applied. |
X-Cline-Pin-Upstreams |
Configured provider list, joined with >. A strict rule still uses only the first entry. |
X-Cline-Pin-Exclude |
Configured deny list for exclude rules, joined with >. Absent for other modes. |
X-Cline-Pin-Mode |
strict, preferred, or exclude. |
X-Cline-Pin-Note |
Why injection was skipped: no match, unreadable model, or injection failure. |
X-Cline-Pin-Unwrapped |
data-envelope when converted; skipped-too-large when over the buffer limit. |
These headers report the proxy's decisions, not the provider Cline actually used, and are not added to ordinary passthrough routes. Upstream x-* headers are forwarded when present; the proxy does not generate an actual-provider header. To check whether a pin took effect, read the routing metadata from the response body as described under Provider routing.
Logs use Go's slog text format on stderr. A pinned request logs model, rule, upstreams, mode, and pipeline:
level=INFO msg="pinned request" model=cline-pass/deepseek-v4.1-flash rule=deepseek upstreams=deepseek mode=strict pipeline=auto
If a non-streaming completion appears empty, check X-Cline-Pin-Unwrapped and where choices sits, and allow enough output tokens: a recorded test with max_tokens: 24 produced empty response content errors on five of six reasoning models, and all six succeeded with 512. That is separate from response wrapping.
- Provider fields are injected only for
POST /v1/chat/completions,/chat/completions, and/api/v1/chat/completions. Other requests under/v1/and/api/v1/are forwarded without request-body rewriting, andOPTIONSis handled locally. - Unmatched or unparseable chat requests retain their original body. Injection failures also fall back to the original body and are marked in the response headers.
- Upstream status codes are preserved. Redirects are returned with
Locationand are not followed. Response bodies are preserved except for the JSON envelope conversion; HTTP framing and compression may be handled by Go's transport. - SSE is copied with a fixed-size buffer and flushed after each read. The proxy has no overall HTTP client timeout; dial and TLS timeouts are 10 seconds each, and the response-header timeout is 120 seconds.
- The normal streaming path aborts the downstream connection if the upstream body ends with a read error, and buffered JSON reads do the same. The oversized JSON fallback has a separate known limitation.
- Each forwarded request uses one configuration snapshot. Injection preserves JSON number precision and does not HTML-escape
<,>, or&. - Request bodies are limited to 64 MiB by default, including on passthrough routes. Over-limit reads return 413; a body of unknown length may already have been partly sent upstream.
- API paths are validated before forwarding: encoded paths and traversal segments are rejected, and requests outside supported routes return 404.
- Request headers are forwarded only for
Content-Type,Accept,Authorization,User-Agent, andforward_headers. Response headers are limited toContent-Type,Content-Encoding,Cache-Control,Retry-After,Location, andx-*; upstreamContent-Lengthis not copied, and buffered JSON responses get a computed length. - The default listener and the Compose host port use loopback. Public API routes have no separate client authentication; protect access with network controls or an authenticated reverse proxy if exposing them beyond trusted clients.
- Keys stored in configuration files are plaintext, and
config.jsonanddata/are git-ignored./admin/configomits the two key fields but returnsprobe_headers; do not treat it as a general secret-redaction endpoint.
See CONTRIBUTING.md for the source layout, checks, and release workflow. User-visible changes per version are recorded in CHANGELOG.md, and the section for a tag becomes that release's GitHub notes. Historical gateway tests are in docs/VERIFICATION.md; the review baseline, fixes, and current limitations are in docs/CODE_REVIEW.md. These detailed records are in Chinese.
Routing knowledge came from public records in cline-pass-switcher, cpagw-gateway, and dsh-cline-pass. No source code was copied from those projects. See NOTICE.
The project focuses on provider routing and response compatibility. It does not include an account pool or web UI.
MIT.