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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
Releases are cut by pushing a `v*` tag, which publishes the images to Docker Hub.
Entries before v0.4.0 were reconstructed from git history.

## Unreleased

### Fixed

- The `proxy` image now renders PDFs that use the standard PDF fonts (Helvetica, Times, Courier). Alpine's `poppler-utils` ships without them, so those pages previously rasterized blank and the model saw nothing. Adds `font-liberation` (about 5 MB, so the image is now ~46 MB).

## v0.4.1 (2026-08-06)

### Changed
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ 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
# font-liberation gives poppler the Base-14 fonts (Helvetica, Times, Courier);
# without it, standard-font PDFs render as blank pages.
RUN apk add --no-cache ca-certificates poppler-utils font-liberation tini
COPY --from=proxy-builder /out/localaik /usr/local/bin/localaik
ENV PORT=8090
# No inference engine here, so the loopback default could never work.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ 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 |
| `proxy` | none (you supply) | ~46 MB |


Version-pinned tags follow the pattern `v0.1.1-gemma3-4b`, `v0.1.1-gemma3-12b`,
Expand Down
4 changes: 3 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Small, runnable samples that talk to **localaik** on `http://localhost:8090`. Us
docker run -d -p 8090:8090 gokhalh/localaik
```

Or from the repo root: `make docker-up` (defaults to port `18090` set `PORT=8090` if you want the examples unchanged).
Or from the repo root: `make docker-up` (defaults to port `18090`; set `PORT=8090` if you want the examples unchanged).

2. Wait until the model is loaded (`GET /health` returns 200). The first start can take a while.

Expand All @@ -31,6 +31,8 @@ Small, runnable samples that talk to **localaik** on `http://localhost:8090`. Us
| **JavaScript** | [javascript/gemini](javascript/gemini/index.mjs) | [javascript/openai](javascript/openai/index.mjs) | not yet | [javascript/gemini-structured](javascript/gemini-structured/index.mjs) |
| **Java** | [java/gemini](java/gemini/Gemini.java) | [java/openai](java/openai/OpenAI.java) | not yet | [java/gemini-structured](java/gemini-structured/GeminiStructured.java) |

**PDF parsing:** [go/gemini-pdf](go/gemini-pdf/main.go) sends a fake invoice PDF through Gemini and extracts its fields as structured JSON. It ships an embedded sample, so `go run main.go` works with no setup; pass a path to use your own PDF.

## Conventions

- **Base URL:** `http://localhost:8090` for Gemini-style calls; OpenAI clients use `http://localhost:8090/v1`; Anthropic clients use `http://localhost:8090` (their SDKs append `v1/` themselves).
Expand Down
90 changes: 90 additions & 0 deletions examples/go/gemini-pdf/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package main

import (
"context"
_ "embed"
"encoding/json"
"fmt"
"log"
"os"

"google.golang.org/genai"
)

// A small fake invoice so the example runs with no setup. Pass a path argument
// to use your own PDF instead.
//
//go:embed sample.pdf
var samplePDF []byte

type invoice struct {
InvoiceNumber string `json:"invoice_number"`
Vendor string `json:"vendor"`
AmountDue string `json:"amount_due"`
DueDate string `json:"due_date"`
}

func main() {
ctx := context.Background()

pdfBytes := samplePDF
if len(os.Args) > 1 {
data, err := os.ReadFile(os.Args[1])
if err != nil {
log.Fatal(err)
}
pdfBytes = data
}

client, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: "test",
Backend: genai.BackendGeminiAPI,
HTTPOptions: genai.HTTPOptions{
BaseURL: "http://localhost:8090",
},
})
if err != nil {
log.Fatal(err)
}

// A schema plus temperature 0 make the model read the fields off the page
// rather than inventing plausible-looking ones.
config := &genai.GenerateContentConfig{
Temperature: genai.Ptr[float32](0),
ResponseMIMEType: "application/json",
ResponseSchema: &genai.Schema{
Type: genai.TypeObject,
Properties: map[string]*genai.Schema{
"invoice_number": {Type: genai.TypeString},
"vendor": {Type: genai.TypeString},
"amount_due": {Type: genai.TypeString},
"due_date": {Type: genai.TypeString},
},
Required: []string{"invoice_number", "vendor", "amount_due", "due_date"},
},
}

resp, err := client.Models.GenerateContent(ctx,
"localaik",
[]*genai.Content{{
Parts: []*genai.Part{
{Text: "Extract the invoice fields from this document."},
genai.NewPartFromBytes(pdfBytes, "application/pdf"),
},
}},
config,
)
if err != nil {
log.Fatal(err)
}

var inv invoice
if err := json.Unmarshal([]byte(resp.Text()), &inv); err != nil {
log.Fatalf("response was not valid JSON: %v\nraw: %s", err, resp.Text())
}

fmt.Printf("invoice number: %s\n", inv.InvoiceNumber)
fmt.Printf("vendor: %s\n", inv.Vendor)
fmt.Printf("amount due: %s\n", inv.AmountDue)
fmt.Printf("due date: %s\n", inv.DueDate)
}
Binary file added examples/go/gemini-pdf/sample.pdf
Binary file not shown.
22 changes: 19 additions & 3 deletions integration/proxy_image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,28 @@ func TestProxyImageRoundTripsAllProtocols(t *testing.T) {
if err != nil {
t.Fatalf("rendered page was not valid base64: %v", err)
}
config, err := png.DecodeConfig(bytes.NewReader(page))
img, err := png.Decode(bytes.NewReader(page))
if err != nil {
t.Fatalf("rendered page was not a valid PNG: %v", err)
}
if config.Width == 0 || config.Height == 0 {
t.Fatalf("rendered page is %dx%d", config.Width, config.Height)
bounds := img.Bounds()
if bounds.Dx() == 0 || bounds.Dy() == 0 {
t.Fatalf("rendered page is %dx%d", bounds.Dx(), bounds.Dy())
}

// A page missing its fonts renders blank, so require some ink. This is
// what catches the proxy image shipping without the PDF base fonts.
ink := 0
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, _ := img.At(x, y).RGBA()
if r < 0x8000 && g < 0x8000 && b < 0x8000 {
ink++
}
}
}
if ink == 0 {
t.Fatal("rendered page has no dark pixels; the proxy image is likely missing PDF fonts (font-liberation)")
}
})

Expand Down
Loading