Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e710838
docs: Spec the proxy-only image variant
harshaneel Aug 4, 2026
da4b1dc
docs: Add implementation plan for the proxy-only image
harshaneel Aug 4, 2026
8bc36e1
feat: Add optional upstream auth header to the proxy
harshaneel Aug 4, 2026
e6d33e4
test: Cover upstream auth on every upstream route
harshaneel Aug 4, 2026
7e4bcb8
test: Add coverage for all upstream-calling routes
harshaneel Aug 4, 2026
1a9885c
feat: Read upstream and auth header from the environment
harshaneel Aug 4, 2026
6257188
feat: Add a proxy-only Dockerfile stage
harshaneel Aug 4, 2026
fe1c0ff
test: Exercise the built proxy image on all three protocols
harshaneel Aug 4, 2026
2adc767
test: Assert per-protocol response shape in proxy image test
harshaneel Aug 4, 2026
6ed2c2a
test: Compress the expected-key comment to one line
harshaneel Aug 4, 2026
1db880d
ci: Publish the proxy image variant
harshaneel Aug 4, 2026
930892d
docs: Document the proxy tag and its security profile
harshaneel Aug 4, 2026
c126b9b
docs: Scope proxy-inapplicable claims in Motivation and Limitations
harshaneel Aug 4, 2026
d28e31d
fix: Withhold the upstream credential from redirect targets
harshaneel Aug 4, 2026
7f8f0af
refactor: Call the shared upstream auth header predicate from main
harshaneel Aug 4, 2026
96a098a
test: Cover PDF rendering in the proxy image, and fix doc findings
harshaneel Aug 4, 2026
54cd22e
docs: Correct the proxy build tag note and record the pin's limit
harshaneel Aug 4, 2026
db23611
chore: Keep docs out of the repo
harshaneel Aug 5, 2026
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
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,25 @@ jobs:
include:
- variant: gemma3-4b
latest: true
target: ""
model_url: https://huggingface.co/lmstudio-community/gemma-3-4b-it-GGUF/resolve/c536c4707e747055eecad7da65d46b6fb0ebaa79/gemma-3-4b-it-Q4_K_M.gguf
model_sha256: be49949e48422e4547b00af14179a193d3777eea7fbbd7d6e1b0861304628a01
mmproj_url: https://huggingface.co/lmstudio-community/gemma-3-4b-it-GGUF/resolve/d400f8ba80bfa661d94a756ea3b663db8b00da85/mmproj-model-f16.gguf
mmproj_sha256: 8c0fb064b019a6972856aaae2c7e4792858af3ca4561be2dbf649123ba6c40cb
- variant: gemma3-12b
latest: false
target: ""
model_url: https://huggingface.co/lmstudio-community/gemma-3-12b-it-GGUF/resolve/ed6e7d1e8c65a0181cd45e3a194722d1c651f06e/gemma-3-12b-it-Q4_K_M.gguf
model_sha256: 9610e3e07375303f6cd89086b496bcc1ab581177f52042eff536475a29283ba2
mmproj_url: https://huggingface.co/lmstudio-community/gemma-3-12b-it-GGUF/resolve/785c151b0dabf00cfaff1421239fafdbb8e5995d/mmproj-model-f16.gguf
mmproj_sha256: 30c02d056410848227001830866e0a269fcc28aaf8ca971bded494003de9f5a5
- variant: proxy
latest: false
target: proxy
model_url: ""
model_sha256: ""
mmproj_url: ""
mmproj_sha256: ""
permissions:
contents: read
steps:
Expand Down Expand Up @@ -96,6 +105,7 @@ jobs:
with:
context: .
file: ./Dockerfile
target: ${{ matrix.target }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/localaik
.cache/
specs/
/specs/
/docs/
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o /out/localaik ./cmd/localaik

FROM alpine:3@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS proxy
RUN apk add --no-cache ca-certificates poppler-utils tini
COPY --from=proxy-builder /out/localaik /usr/local/bin/localaik
ENV PORT=8090
HEALTHCHECK --interval=5s --timeout=3s --start-period=5s \
CMD wget -q -O - "http://127.0.0.1:${PORT:-8090}/health" >/dev/null 2>&1 || exit 1
EXPOSE 8090
ENTRYPOINT ["tini", "--", "localaik"]

# Upstream does not ship semver for the server image; pin by digest for reproducible multi-arch builds.
# Logical tag at pin time: server (includes llama-server --mmproj for Gemma 3 vision). Bump digest to upgrade.
FROM ghcr.io/ggml-org/llama.cpp@sha256:80910e898e5d9a6b46ca9d1b4674d3e15faf6d32b9692eb6011ccd34b2cb8a06
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ GOFILES := $(shell find cmd internal integration -name '*.go' -type f | sort)

export GOCACHE

.PHONY: help fmt fmt-check lint test-unit test-integration test build docker-build docker-up docker-down
.PHONY: help fmt fmt-check lint test-unit test-integration test build docker-build docker-build-proxy docker-up docker-down

help:
@printf '%s\n' \
Expand All @@ -22,6 +22,7 @@ help:
'make test Run lint, unit tests, and integration tests' \
'make build Build the localaik binary' \
'make docker-build Build the Docker image' \
'make docker-build-proxy Build the proxy-only image' \
'make docker-up Start the Docker image on PORT' \
'make docker-down Stop and remove the Docker container'

Expand All @@ -48,6 +49,9 @@ build:
docker-build:
@docker build -t "$(IMAGE)" .

docker-build-proxy:
@docker build --target proxy -t "$(IMAGE)-proxy" .

docker-up:
@if [[ "$(BUILD_IMAGE)" == "1" ]]; then $(MAKE) docker-build IMAGE="$(IMAGE)"; fi
@docker rm -f "$(CONTAINER_NAME)" >/dev/null 2>&1 || true
Expand Down
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A local compatibility server for the Gemini, OpenAI, and Anthropic APIs. Run one

## Motivation

Testing code that calls Gemini, OpenAI, or Anthropic is painful: real API calls are slow, cost money, and need network access. localaik gives you a single Docker container that speaks all three protocols backed by a local modelno API key, no internet, deterministic enough for CI.
Testing code that calls Gemini, OpenAI, or Anthropic is painful: real API calls are slow, cost money, and need network access. localaik gives you a single Docker container that speaks all three protocols backed by a local model, or the `proxy` tag if you already run your own model server. The model-bundled tags need no API key and no internet, and are deterministic enough for CI.

## Architecture

Expand Down Expand Up @@ -133,9 +133,11 @@ client := anthropic.NewClient(
| --------------------- | ------------------ | ---------- |
| `latest`, `gemma3-4b` | Gemma 3 4B Q4_K_M | ~3 GB |
| `gemma3-12b` | Gemma 3 12B Q4_K_M | ~7 GB |
| `proxy` | none (you supply) | ~41 MB |


Version-pinned tags follow the pattern `v0.1.1-gemma3-4b`, `v0.1.1-gemma3-12b`.
Version-pinned tags follow the pattern `v0.1.1-gemma3-4b`, `v0.1.1-gemma3-12b`,
`v0.1.1-proxy`. The `proxy` tag is never published as `latest`.

## Tuning (v0.1.3 onwards)

Expand Down Expand Up @@ -182,6 +184,43 @@ services:
| `LK_MLOCK` | 0 (off) | Lock model in RAM (`1` to enable) |


## Bring your own model server (`:proxy`)

If you already run llama.cpp, vLLM, or anything else that speaks the OpenAI
chat-completions API, the `proxy` tag gives you the translation layer alone. It
contains no model and no inference engine.

```bash
docker run -d -p 127.0.0.1:8090:8090 \
-e LK_UPSTREAM=http://llama.internal:8080/v1 \
gokhalh/localaik:proxy
```

| Env var | Default | Description |
| --- | --- | --- |
| `LK_UPSTREAM` | `http://127.0.0.1:8080/v1` | Base URL of your model server |
| `LK_UPSTREAM_AUTH_HEADER` | unset | A full header line sent to your server, for example `Authorization: Bearer abc123` |
| `PORT` | `8090` | Port localaik listens on |

`LK_UPSTREAM_AUTH_HEADER` is sent only to your upstream. Credentials that
clients send to localaik are still discarded and never forwarded. It is attached
only to requests whose host matches `LK_UPSTREAM`, and while it is set a
redirect from your upstream is returned to the caller rather than followed.

`/health` returns 503 until your upstream answers, so existing healthchecks and
CI wait loops work unchanged.

### Security

`:proxy` has a different risk profile from the model-bundled tags. Those keep
llama.cpp bound to localhost inside the container, so the only thing reachable
is a disposable local model. `:proxy` forwards into infrastructure you care
about, and localaik does not authenticate its callers by design.

**Anyone who can reach port 8090 can use your model server without
credentials.** Bind to localhost and do not publish the port on a shared
network. localaik is a testing tool, not a gateway.

## Implemented routes


Expand Down Expand Up @@ -357,12 +396,15 @@ docker build \
--build-arg MMPROJ_URL=... \
--build-arg MMPROJ_SHA256=... \
-t gokhalh/localaik:custom .

# Proxy only, no model or inference engine
docker build --target proxy -t gokhalh/localaik:proxy .
```

## Limitations

- Intended for tests and development, not production
- Image size is dominated by model weights
- Image size is dominated by model weights (not applicable to the `proxy` tag, which ships none)
- Cold starts can take tens of seconds while the model loads
- PDF rendering adds latency per page

26 changes: 17 additions & 9 deletions cmd/localaik/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,28 @@ import (
"github.com/harshaneel/localaik/internal/server"
)

func main() {
defaultPort := os.Getenv("PORT")
if defaultPort == "" {
defaultPort = "8090"
func resolveFlagDefault(envName, fallback string) string {
if value := os.Getenv(envName); value != "" {
return value
}
return fallback
}

port := flag.String("port", defaultPort, "port to listen on")
upstream := flag.String("upstream", "http://127.0.0.1:8080/v1", "upstream OpenAI-compatible base URL")
func main() {
port := flag.String("port", resolveFlagDefault("PORT", "8090"), "port to listen on")
upstream := flag.String("upstream", resolveFlagDefault("LK_UPSTREAM", "http://127.0.0.1:8080/v1"), "upstream OpenAI-compatible base URL")
flag.Parse()

authHeader := os.Getenv("LK_UPSTREAM_AUTH_HEADER")
if authHeader != "" && !server.ValidUpstreamAuthHeader(authHeader) {
log.Printf("localaik: LK_UPSTREAM_AUTH_HEADER is set but is not a valid \"Name: value\" header line; no credential will be sent upstream")
}

handler, err := server.New(server.Config{
UpstreamBaseURL: *upstream,
HTTPClient: &http.Client{},
PDFRenderer: pdf.NewExecRenderer("pdftoppm"),
UpstreamBaseURL: *upstream,
UpstreamAuthHeader: authHeader,
HTTPClient: &http.Client{},
PDFRenderer: pdf.NewExecRenderer("pdftoppm"),
})
if err != nil {
log.Fatalf("localaik: %v", err)
Expand Down
40 changes: 40 additions & 0 deletions cmd/localaik/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"testing"

"github.com/harshaneel/localaik/internal/server"
)

func TestResolveFlagDefaultPrefersEnv(t *testing.T) {
t.Setenv("LK_TEST_VALUE", "from-env")

if got := resolveFlagDefault("LK_TEST_VALUE", "fallback"); got != "from-env" {
t.Fatalf("resolveFlagDefault = %q, want from-env", got)
}
}

func TestResolveFlagDefaultFallsBack(t *testing.T) {
t.Setenv("LK_TEST_VALUE", "")

if got := resolveFlagDefault("LK_TEST_VALUE", "fallback"); got != "fallback" {
t.Fatalf("resolveFlagDefault = %q, want fallback", got)
}
}

func TestResolveFlagDefaultUnsetFallsBack(t *testing.T) {
if got := resolveFlagDefault("LK_DEFINITELY_UNSET_VALUE", "fallback"); got != "fallback" {
t.Fatalf("resolveFlagDefault = %q, want fallback", got)
}
}

// The startup warning must be driven by the same predicate the transport uses;
// server.ValidUpstreamAuthHeader owns the table of cases.
func TestStartupWarningUsesTheServerPredicate(t *testing.T) {
if server.ValidUpstreamAuthHeader("Authorization: Bearer token123") != true {
t.Fatal("a valid header line was rejected")
}
if server.ValidUpstreamAuthHeader("InvalidHeader NoColon") != false {
t.Fatal("a header line with no colon was accepted")
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25
require (
github.com/anthropics/anthropic-sdk-go v1.61.0
github.com/openai/openai-go/v3 v3.36.0
golang.org/x/net v0.41.0
google.golang.org/genai v1.57.0
)

Expand All @@ -29,7 +30,6 @@ require (
go.opencensus.io v0.24.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
Expand Down
Loading
Loading