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
28 changes: 28 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
version: 2

updates:
# The workflow pins actions to commit SHAs, which is the point — a mutable
# tag is a stranger's push away from running in CI. Pinning without this
# file just trades a supply-chain hole for a stale one, so let Dependabot
# move the pins; it rewrites the SHA and the trailing version comment
# together.
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
# Two first-party actions. Separate PRs for them is noise, not signal.
groups:
actions:
patterns: ["*"]

- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
day: monday
groups:
# Patch and minor bumps land as one PR that CI either passes or does
# not. Majors stay separate, because those are the ones worth reading.
go-minor:
update-types: [minor, patch]
46 changes: 42 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,70 @@ on:
- cron: "0 6 * * 1"
workflow_dispatch:

# go test runs arbitrary code from the PR, so its token gets read and nothing
# else. Also set in repo settings; this copy is the one that shows up in a diff.
permissions:
contents: read

# Cancel a superseded PR run on force-push. Not main or the weekly sweep:
# each of those runs is the record for one commit.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
check:
runs-on: ubuntu-latest
# The default is 6 hours. The race suite takes a couple of minutes.
timeout-minutes: 15
env:
# The runner image ships no golangci-lint, and `make lint` used to fall
# back to `go vet` when it was missing, so this step passed for months
# without linting. Pinned like gitleaks, and to the version developers
# run locally, so CI and a laptop disagree only when the code differs.
GOLANGCI_VERSION: 2.13.2
GOLANGCI_SHA256: 2277d43b98ec0054280f2ac26b53268bae97682444678a59a657dd565da021d6
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Without this the job's token is left behind in .git/config, where
# the pull request's own test code can read it.
persist-credentials: false

- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod

# fmt + vet + lint + the full suite under -race.
- name: Install golangci-lint
run: |
set -euo pipefail
tarball="golangci-lint-${GOLANGCI_VERSION}-linux-amd64.tar.gz"
curl -sSfL -o "$tarball" \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_VERSION}/${tarball}"
echo "${GOLANGCI_SHA256} ${tarball}" | sha256sum -c -
tar -xzf "$tarball" --strip-components=1 \
"golangci-lint-${GOLANGCI_VERSION}-linux-amd64/golangci-lint"
sudo install golangci-lint /usr/local/bin/golangci-lint

# fmt + vet + lint + the full suite under -race. CI is set by Actions, so
# a missing linter fails here instead of quietly downgrading to go vet.
- run: make check

secrets:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# Pinned and checksummed. An unpinned scanner is a supply-chain hole in
# the one job whose purpose is supply-chain hygiene.
GITLEAKS_VERSION: 8.30.1
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# gitleaks walks real history; the default shallow clone gives it one
# commit and nothing to walk.
fetch-depth: 0
persist-credentials: false

- name: Install gitleaks
run: |
Expand Down
10 changes: 10 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: "2"

issues:
# The defaults stop at 3 of a kind and 50 per linter, which quietly turns a
# full run into a sample. A gate has to report everything it found.
max-issues-per-linter: 0
max-same-issues: 0
# One issue per line by default, so a second linter's finding on an already
# flagged line is dropped. That hid an SA1019 deprecation behind a govet hint.
uniq-by-line: false
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ A confidently wrong verdict is worse than no verdict. When evidence is
unavailable — an undecodable body, a bodyless response — report that, never
guess.

`routes[].strip_params` (`internal/proxy/rewrite.go`) is the single exception to
byte-for-byte passthrough, and the only code that edits a client's body. It
exists because some vendors reject a parameter the client's SDK sends
unconditionally. Keep it narrow — top-level keys, JSON objects, opt-in per
route — and keep every strip in the report. An edit the report does not
disclose turns every verdict on that route into a guess.

## Layout

| Package | Responsibility |
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,15 @@ bench:
vet:
go vet ./...

## lint: golangci-lint when installed, otherwise go vet
## lint: golangci-lint when installed, otherwise go vet (an error under CI)
.PHONY: lint
lint:
ifdef GOLANGCI
golangci-lint run ./...
else ifdef CI
@echo "golangci-lint is not installed; the go vet fallback would report a" >&2
@echo "green lint step that linted almost nothing. Install it in the workflow." >&2
@exit 1
else
@echo "golangci-lint not installed; falling back to go vet (make tools to install)"
@go vet ./...
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Everything else has a default. The settings worth knowing:
| `log.full_trace` | off | Every header, body and SSE frame with arrival times. Keys stay redacted. |
| `routes[].retry` | off | Retry 429s and 5xx, honouring `Retry-After`. Off means transparent. |
| `routes[].timeouts.*` | no limit | See [Waiting](#waiting). |
| `routes[].strip_params` | none | Delete top-level JSON keys from request bodies. See [When a vendor rejects a parameter](#when-a-vendor-rejects-a-parameter). |
| `auth.tokens` | none | See [Exposing it](#exposing-it). `auth.enabled: false` ignores tokens and the environment entirely. |

## Waiting
Expand Down Expand Up @@ -144,6 +145,43 @@ A deadline the proxy enforces still blames the vendor for going silent, and the
report names the setting that fired, so you can tell it apart from a real
vendor failure.

## When a vendor rejects a parameter

Some OpenAI-compatible backends refuse a parameter your client sends
unconditionally, and there is no way to turn it off client-side:

```json
{"message":"Validation: Unsupported parameter(s): `prompt_cache_key`","type":"Bad Request","code":400}
```

`strip_params` deletes top-level keys from the request body before forwarding:

```yaml
routes:
- name: nebul
upstream: https://api.nebul.example
api_key_env: NEBUL_API_KEY
strip_params:
- prompt_cache_key
```

This is the one setting that makes the proxy edit what a client sent, so it
comes with strings attached:

- Only top-level keys, and only when the body is a JSON object. Anything else
goes through untouched.
- Values that survive are re-encoded from their original bytes, so numbers keep
their precision and prompts keep their `<tags>`. Top-level key order and
whitespace do change.
- A body over `max_request_body` is streamed rather than buffered and cannot be
rewritten. The request goes through intact and the report says the strip did
not run.
- `model`, `messages` and `stream` are refused at startup: removing those breaks
the request instead of fixing it.
- Startup names the route as rewriting requests, and every strip that removed
something appears in that request's report as `stripped=…`. A verdict about
which side broke a request has to admit the proxy edited it first.

## Exposing it

With no tokens configured the proxy is open, which is fine on `127.0.0.1`. If
Expand Down
12 changes: 6 additions & 6 deletions cmd/demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,14 +252,14 @@ func streamHandler(n int, end ending, finish string) http.HandlerFunc {
if err != nil {
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()

writeHead(rw)
for i := 0; i < n; i++ {
frame := fmt.Sprintf(
`data: {"id":"chatcmpl-demo","model":"demo-model","choices":[{"index":0,"delta":{"content":"tok%d"}}]}`+"\n\n", i)
if end == endTruncate && i == n-1 {
fmt.Fprintf(rw, "%x\r\n%s", len(frame), frame[:len(frame)/2])
_, _ = fmt.Fprintf(rw, "%x\r\n%s", len(frame), frame[:len(frame)/2])
_ = rw.Flush()
return
}
Expand Down Expand Up @@ -291,7 +291,7 @@ func slowFirstToken(think time.Duration) http.HandlerFunc {
if err != nil {
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()

writeHead(rw)
time.Sleep(think)
Expand All @@ -311,7 +311,7 @@ func errorMidStream() http.HandlerFunc {
if err != nil {
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()
writeHead(rw)
writeChunk(rw, `data: {"choices":[{"index":0,"delta":{"content":"partial answer"}}]}`+"\n\n")
writeChunk(rw, `data: {"error":{"message":"the model backend crashed","type":"server_error"}}`+"\n\n")
Expand Down Expand Up @@ -358,7 +358,7 @@ func writeHead(rw *bufio.ReadWriter) {
}

func writeChunk(rw *bufio.ReadWriter, data string) {
fmt.Fprintf(rw, "%x\r\n%s\r\n", len(data), data)
_, _ = fmt.Fprintf(rw, "%x\r\n%s\r\n", len(data), data)
_ = rw.Flush()
}

Expand All @@ -370,7 +370,7 @@ func hangUpMidStream(addr string) {
return
}
body := `{"model":"demo-model","stream":true,"messages":[{"role":"user","content":"hello"}]}`
fmt.Fprintf(conn, "POST /demo/v1/chat/completions HTTP/1.1\r\nHost: demo\r\n"+
_, _ = fmt.Fprintf(conn, "POST /demo/v1/chat/completions HTTP/1.1\r\nHost: demo\r\n"+
"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body)

buf := make([]byte, 4096)
Expand Down
13 changes: 9 additions & 4 deletions cmd/llm-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func run() error {
fs.BoolVar(&f.check, "check", false, "validate the config, print the route table and exit")
fs.BoolVar(&f.showVersion, "version", false, "print the version and exit")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "llm-proxy — an OpenAI-compatible wire-level debugging proxy\n\nusage: llm-proxy [flags]\n\n")
_, _ = fmt.Fprintf(fs.Output(), "llm-proxy — an OpenAI-compatible wire-level debugging proxy\n\nusage: llm-proxy [flags]\n\n")
fs.PrintDefaults()
}
if err := fs.Parse(os.Args[1:]); err != nil {
Expand All @@ -75,15 +75,15 @@ func run() error {
" see llm-proxy.example.yaml for a starting point")
}

cfg, warnings, err := config.Load(path)
cfg, _, err := config.Load(path)
if err != nil {
return fmt.Errorf("config %s:\n%w", path, err)
}
applyFlags(cfg, &f)
// Re-validate after the flags, and keep *these* warnings: -listen can move
// the proxy onto a public address, and the warning about an unguarded
// listener has to reflect where it will actually listen.
warnings, err = cfg.Validate()
warnings, err := cfg.Validate()
if err != nil {
return fmt.Errorf("config %s:\n%w", path, err)
}
Expand Down Expand Up @@ -187,7 +187,7 @@ func printRouteTable(c *config.Config, path string, warnings []config.Warning) {
}

for _, r := range c.Routes {
auth := "none"
var auth string
switch {
case r.APIKeyEnv == "":
auth = "client-supplied only"
Expand All @@ -211,6 +211,11 @@ func printRouteTable(c *config.Config, path string, warnings []config.Warning) {
fmt.Printf(" auth=%s %s retry=%s\n", auth, proto, retry)
fmt.Printf(" waits: first byte %s, between chunks %s, progress every %s\n",
limit(r.Timeouts.ResponseHeader), limit(r.Timeouts.StreamIdle), limit(r.Timeouts.GapWarn))
// Only printed when it is on: this is the one setting under which the
// route no longer forwards what the client actually sent.
if len(r.StripParams) > 0 {
fmt.Printf(" rewriting requests: stripping %s\n", strings.Join(r.StripParams, ", "))
}
fmt.Printf(" client base_url: http://%s/%s/v1\n\n", c.Listen, r.Name)
}

Expand Down
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,21 @@ type RouteOpts struct {
// which many OpenAI-compatible servers do.
ExpectDone string `yaml:"expect_done"`

// StripParams are top-level JSON keys deleted from a request body before it
// is forwarded. This is the only place llm-proxy edits what a client sent,
// and it is off unless a route asks for it by name.
//
// It exists for one specific interop failure: an OpenAI-compatible vendor
// that rejects a parameter the client's SDK sends unconditionally —
// `Unsupported parameter(s): prompt_cache_key` — where the client offers no
// way to stop sending it. Stripping it at the proxy is the only place the
// fix can go.
//
// Every strip that actually removes something is recorded and shown in the
// report, because a verdict about which side broke a request is worthless
// if the report does not admit the request was rewritten on the way past.
StripParams []string `yaml:"strip_params"`

// AbortOnTruncation aborts the downstream connection when a stream is
// found truncated, so the client cannot mistake it for a complete answer.
AbortOnTruncation *bool `yaml:"abort_on_truncation"`
Expand Down
Loading