feat: Publish a proxy-only image and support a remote upstream - #12
Merged
Conversation
Design for a `:proxy` tag containing the translating proxy and poppler but no inference stack, for users who already run llama.cpp or a shared internal model server. The Go binary already accepts any OpenAI-compatible upstream, so the work is packaging plus the upstream credential the proxy currently cannot send. Also anchors the specs/ gitignore rule to the repo root. It had no leading slash, so it matched at any depth and would have silently swallowed this file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight tasks, TDD throughout. Alpine base, poppler-utils, tini and busybox wget were verified against a real build: pdftoppm 25.12.0 is present and the base plus packages measures 35 MB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Injected at the transport layer so every upstream request carries it without each call site opting in.
Locks both halves at once: the proxy credential is added, the caller's is not forwarded.
Adds subtests for /health, /v1/completions, /v1/models/{id}, /v1beta/models,
and /v1beta/models/{id} routes. Fixes streaming test to assert status code and
response body content.
Follows the pattern PORT already set, so the container needs no shell wrapper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
alpine plus poppler-utils and the binary, no inference stack. The llama.cpp stage stays last so the default build is unchanged.
Builds the proxy Dockerfile target, runs it against a stub upstream reached via host.docker.internal, and drives OpenAI, Gemini, and Anthropic-shaped requests through it end to end. Also fixes a leaked listener fd, a stale-container name collision, and an unsynchronized cross-goroutine read found during review of the brief's literal code.
The stub returns one OpenAI-shaped payload for every route, and the per-subtest assertion only checked for a non-empty JSON object. That would pass even if routing sent a Gemini or Anthropic request to the OpenAI passthrough handler. Assert the expected top-level key per protocol (choices/candidates/content) and the assistant role on the Anthropic response, so a misrouted handler fails the test.
Adds a build target to the matrix. Empty target keeps the existing entries on the default final stage.
The transport set LK_UPSTREAM_AUTH_HEADER on every request it saw. An http.Client re-enters its RoundTripper for each redirect hop, and the stdlib strips Authorization only on the request it built itself, so setting the header per-request re-added it after that strip. A 302 from the upstream handed the credential to whatever host the Location named. The transport now pins on the configured upstream hostname, and the credentialed client no longer follows redirects at all, since a followed redirect would also send prompt content to the target. Redirect handling is left at the stdlib default when no credential is configured, so the model-bundled images are unaffected. Also extracts one header predicate, now rejecting names and values that net/http would reject at the wire on every request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main.go had its own copy of the validation rules, so the startup warning could drift from what the transport actually does and claim no credential would be sent while one was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec required the PDF path be exercised inside the proxy image rather than only against the Debian-based one, since alpine's poppler is a different build. The README quickstart also published port 8090 on every interface, which is what its own security warning tells operators not to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The make target tags $(IMAGE)-proxy, not gokhalh/localaik:proxy, so the annotation implied a tag the command does not produce. The CheckRedirect comment now names the invariant a reviewer found: the transport pins hostname only, so relaxing redirect handling would reopen cross-port and scheme-downgrade leakage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 4, 2026
Design docs and plans are local working notes, the same treatment /specs/ already gets. Untracks the two that landed, which stay on disk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
:proxyimage variant, 41 MB. Alpine pluspdftoppmplus the Go binary, with no inference engine and no model. For anyone who already runs an OpenAI-compatible server and wants only the translation layer instead of pulling 3.16 GB and running a second model they will never call. Added as a middleDockerfilestage so the llama.cpp stage stays last and a baredocker build .keeps producing the full image.LK_UPSTREAMandLK_UPSTREAM_AUTH_HEADER, with flag beating environment beating default, so the existing entrypoint keeps working unchanged.LK_UPSTREAM_AUTH_HEADERtakes a complete header line (Authorization: Bearer abc123) rather than a bare token, which covers Bearer, llama.cpp's--api-key, and custom schemes without the proxy needing to know which is in use.http.Client, so aRoundTripperwrapper covers every route including streaming and cannot be forgotten when a seventh path is added. Credentials that clients send to localaik are still stripped and never forwarded; the two properties are asserted together across 11 routes.proxyandvX.Y.Z-proxy, neverlatest. Anyone pullinglatesttoday gets a self-contained container, and silently turning that into one requiring an external server would break them. The existinggemma3-4bandgemma3-12bentries are unchanged apart from an added emptytarget, whichdocker/build-push-actionomits when falsy.Security fix found during review
Three independent pre-PR reviews ran over this branch. Two of them separately reproduced a credential-exfiltration path in the first version of the transport, which is fixed in
d28e31d:RoundTripset the header on every request it saw. Anhttp.Clientre-enters itsRoundTripperfor each redirect hop, and the standard library stripsAuthorizationonly from the request it built itself, so setting the header per-request re-added it after that strip. A302from the upstream handed the credential to whatever host theLocationnamed, over cleartext by default.The fix pins the header to the configured upstream hostname, and the credentialed client no longer follows redirects at all, since a followed redirect would also send prompt content to the target. Redirect handling stays at the standard library default when no credential is configured, so the model-bundled images are unaffected. A follow-up review confirmed the fix holds under adversarial probing (redirect chains, case and trailing-dot hostname variants, port and scheme changes, IPv6 literals, userinfo spoofing) and that each of the three new tests fails when the specific change it pins is reverted.
One residual note is recorded in a comment at the
CheckRedirectsite: the pin is on hostname only, so relaxing redirect handling later would reopen cross-port and scheme-downgrade leakage.Reviewer-flagged follow-ups, deliberately not in this PR
USERdirective.entrypoint.shhas a pre-existing bug, present onmainand untouched here: a hardcoded 120s model-load timeout whosecleanup()then blocks forever inwait, because the kill does not terminatellama-server. Under host load the container wedges with llama.cpp healthy on 8080 and nothing listening on 8090.integration/proxy_image_test.gobuilds an image inside the test and hardcodes its container name and host port, so two concurrent local runs collide.Test Plan
make lintclean.go test ./cmd/... ./internal/...all pass.go test -tags=docker_integration ./integration -run ProxyImagepasses: the built proxy image round-trips an OpenAI, a Gemini and an Anthropic request, and renders a PDF to PNG using alpine'spoppler-utils.{"status":"ok"}, confirming the added stage did not displace the llama.cpp stage.git diff main -- Dockerfileis the inserted stage only.docker run -e LK_UPSTREAM_AUTH_HEADER=... 2>&1 | grep -c <sentinel>returns 0, for both the sentinel andBearer.Bearer a:b) are accepted; names and values thatnet/httpwould reject at the wire are rejected at startup instead.To try it:
/healthreturns 503 until the configured upstream answers, 200 once it does.:proxyhas a different risk profile from the bundled tags: it is a network hop into infrastructure you care about, and localaik authenticates none of its own callers by design. Anyone who can reach the port can drive your model server. Bind to localhost and do not publish it on a shared network.🤖 Generated with Claude Code