- Go 96.7%
- Makefile 1.7%
- Dockerfile 1.6%
| config | ||
| database | ||
| docs | ||
| handlers | ||
| middleware | ||
| models | ||
| utils | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| Makefile | ||
| README.md | ||
| SYSTEM.md | ||
go-proxy
OpenAI SDK compatible proxy built on Go Fiber. Forwards /v1/chat/completions and /v1/models to an upstream OpenAI-compatible endpoint, enforces Bearer-token API keys with per-key token limits and expiry, counts tokens with tiktoken-go, injects a configurable system prompt from SYSTEM.md, supports multi-provider routing with model aliasing, and normalizes every upstream/downstream failure into a single error envelope.
Multi-provider + model aliasing
Every API key is bound to a Provider (url + api_key). Models are stored as local_name to provider_model mappings, scoped to a provider. The flow:
GET /v1/modelsreads fromprovider_modelsfiltered by the caller'sprovider_id. Clients only ever see configuredlocal_names. No upstream call is made.POST /v1/chat/completionsresolveslocal_name->provider_modelfor the caller's provider, swaps the field in the outgoing payload, calls<provider.url>/chat/completionswithprovider.api_key, and rewrites themodelfield in the response (and every SSE chunk) back tolocal_namebefore sending it to the client.
Operators populate the data with plain SQL or any GORM tool. The proxy seeds a single default provider from EXTERNAL_OPENAI_ROUTE/EXTERNAL_OPENAI_KEY on the first run so a fresh install is usable, but no models are seeded; insert rows into provider_models to expose anything.
Setup
-
Copy env and edit values:
Copy-Item .env.example .env -
Install deps and run:
go mod tidy go run .On first start the service seeds one API key and prints it in the log. Use it as the client
Authorization: Bearer <key>token.
Routes
All routes require Authorization: Bearer <apiKey> except the public docs endpoints.
GET /docsSwagger UI (public)GET /openapi.yamlraw OpenAPI 3.1 spec (public)GET /healthliveness + database pingGET /infometadata of the caller's API key (never returns the raw key)GET /v1/modelsproxy to upstream/modelsPOST /v1/chat/completionsproxy with streaming and non-streaming support. Token usage is counted and written back to the caller's key.
API key model
Stored via GORM (models.ApiKey, table api_keys). The SQL columns token_limit and token_usage avoid reserved words so the schema works on SQLite, MySQL, and PostgreSQL without provider-specific quoting.
| field | column | type | notes |
|---|---|---|---|
| id | id | varchar(36) | primary key, auto-generated uuid |
| apiKey | api_key | varchar(128) | unique, used as Bearer token |
| providerId | provider_id | varchar(36) | FK -> providers.id, required |
| limit | token_limit | bigint | 0 means unlimited |
| usage | token_usage | bigint | running token total |
| expiredAt | expired_at | datetime? | null means never expires |
| createdAt | created_at | datetime | auto set |
| lastUsedAt | last_used_at | datetime? | updated (coalesced, async) on auth |
Provider (models.Provider, table providers)
| field | column | type | notes |
|---|---|---|---|
| id | id | varchar(36) | primary key, uuid |
| name | name | varchar(128) | unique |
| url | url | varchar(512) | upstream base URL, e.g. https://api.openai.com/v1 |
| apiKey | api_key | varchar(512) | bearer token sent upstream |
| createdAt | created_at | datetime | auto |
| updatedAt | updated_at | datetime | auto |
ProviderModel (models.ProviderModel, table provider_models)
| field | column | type | notes |
|---|---|---|---|
| id | id | varchar(36) | primary key, uuid |
| providerId | provider_id | varchar(36) | FK -> providers.id |
| localName | local_name | varchar(128) | what clients send as model |
| providerModel | provider_model | varchar(128) | what upstream receives as model |
| createdAt | created_at | datetime | auto |
| updatedAt | updated_at | datetime | auto |
(provider_id, local_name) is unique.
Database providers
Select a provider via DATABASE_PROVIDER. Supported values: sqlite (default), mysql, postgres (alias postgresql).
sqlite: uses pure-Goglebarez/sqlite, no CGO needed.DATABASE_DSNorDATABASE_PATHcan be a file path (defaults togo-proxy.db).mysql:DATABASE_DSNexample:user:pass@tcp(127.0.0.1:3306)/goproxy?charset=utf8mb4&parseTime=True&loc=Local.postgres:DATABASE_DSNexample:host=127.0.0.1 user=postgres password=postgres dbname=goproxy port=5432 sslmode=disable.
MySQL and PostgreSQL require DATABASE_DSN. SQLite is used as the fallback when the provider is missing or unknown.
Error envelope
Upstream OpenAI errors are never forwarded as-is. Every failure (auth, upstream, proxy-internal, network) is rewritten into a stable envelope:
{
"error": {
"message": "Invalid API key",
"type": "unauthorized",
"code": "invalid_upstream_key"
}
}
Mappings (non-exhaustive):
| upstream / source | proxy status | type | code |
|---|---|---|---|
| upstream 401 | 401 | unauthorized | invalid_upstream_key |
| upstream 403 | 403 | forbidden | upstream_forbidden |
| upstream 404 | 404 | not_found | resource_not_found |
| upstream 429 | 429 | rate_limit | upstream_rate_limited |
| upstream 413 | 413 | invalid_request | payload_too_large |
| upstream 400 | 400 | invalid_request | invalid_request / upstream code |
| upstream 422 | 422 | invalid_request | unprocessable_request |
| upstream other 4xx | upstream | client_error | upstream_client_error |
| upstream 502 / 503 / 504 | 502 | upstream_unavailable | upstream_unavailable |
| upstream 5xx | 502 | upstream_error | upstream_error |
| cannot reach upstream | 502 | upstream_unreachable | upstream_unreachable |
| proxy-side failure | 500 | internal_error | internal_error |
| malformed client request | 400 | invalid_request | invalid_request |
| missing / invalid bearer token | 401 | unauthorized | invalid_api_key |
| expired key | 403 | forbidden | key_expired |
| key without provider_id | 403 | forbidden | key_missing_provider |
| over token limit | 429 | rate_limit | limit_exceeded |
| unknown model for key's provider | 404 | not_found | model_not_found |
| unknown route | 404 | not_found | route_not_found |
Env
| variable | default | notes |
|---|---|---|
EXTERNAL_OPENAI_ROUTE |
https://api.openai.com/v1 |
trailing slash is stripped |
EXTERNAL_OPENAI_KEY |
(required) | bearer token used to call upstream |
PORT |
3000 |
|
SYSTEM_PROMPT_PATH |
SYSTEM.md |
|
BODY_LIMIT_BYTES |
16777216 (16 MiB) |
request body cap |
READ_TIMEOUT |
30s |
Go duration |
WRITE_TIMEOUT |
10m |
streamed completions can run long |
IDLE_TIMEOUT |
2m |
keep-alive idle timeout |
SHUTDOWN_TIMEOUT |
20s |
grace period on SIGINT / SIGTERM |
TRUSTED_PROXIES |
(empty) | comma-separated IPs/CIDRs allowed to set X-Forwarded-* |
DATABASE_PROVIDER |
sqlite |
sqlite / mysql / postgres |
DATABASE_DSN |
(required for mysql / postgres) | |
DATABASE_PATH |
go-proxy.db (sqlite only) |
DSN overrides this if set |
Tests
go test ./...
Or with the Makefile (Linux/macOS/WSL/Git Bash):
make test
make cover
Docker
docker build -t go-proxy:latest .
docker run --rm -p 3000:3000 \
-e EXTERNAL_OPENAI_KEY=sk-... \
-v "$(pwd)/data:/data" \
go-proxy:latest
The image is built on distroless/static and runs as a non-root user.
Docker Compose
EXTERNAL_OPENAI_KEY=sk-... docker compose up -d
Configuration is pulled from a .env file in the project root (copy from .env.example). Only EXTERNAL_OPENAI_KEY is required; everything else has sensible defaults. SQLite data is persisted in the go-proxy-data Docker volume.
Operational notes
- Graceful shutdown. SIGINT / SIGTERM drains in-flight requests for up to
SHUTDOWN_TIMEOUTbefore closing the database pool. - Connection reuse. Upstream HTTP traffic uses a shared
*http.Transport, so HTTP/2, TLS session resumption, and idle-connection pooling are preserved across requests. - last_used_at coalescing. Per-key
last_used_atupdates are throttled to one write every 30 seconds and run asynchronously, so a request burst does not translate into a write burst on the database. - Seeded API key. The default key is logged exactly once on the first start. Recover it later by querying the
api_keystable; the proxy never logs it again on subsequent restarts.