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
8 changes: 8 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
# Header used to read/write request user_path values (default: X-GoModel-User-Path)
# USER_PATH_HEADER=X-GoModel-User-Path

# Where the running gateway records its process id so `gomodel --reload` can find it
# (default: data/gomodel.pid next to a ./data directory, otherwise the per-user data
# directory). Set it per instance when several gateways share a host. Leaving this
# empty means "unset" and keeps the default, as everywhere else here; to write no pid
# file at all (which also disables --reload), set `server.pid_file: ""` in config.yaml.
# Changing it takes effect on the next restart, not on `gomodel --reload`.
# PID_FILE=data/gomodel.pid

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Reject unknown keys in config.yaml and in the JSON env vars that declare the same
# structures (VIRTUAL_MODELS, SET_RATE_LIMIT_*, SET_BUDGET_*). Default: true, so a
# typo or a misindented section fails startup instead of silently dropping providers,
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Full reference: `.env.template` and `config/config.yaml`
- `GOMODEL_MASTER_KEY` (empty = unsafe mode). Managed API keys (dashboard API Keys page / `POST /admin/auth-keys`) carry a per-key `dashboard_access` flag (default false, changeable via `PUT /admin/auth-keys/{id}/dashboard-access`): only the master key and flagged keys can call the admin REST API endpoints under `/admin/*` (others get 403 `dashboard_access_denied`); the dashboard UI shell and static assets (`/admin/dashboard`, `/admin/static/*`) skip auth entirely — only the admin data they load is gated; model endpoints and `GET /v1/usage` stay open to every key, and the no-master-key lockout-recovery path (auth skipped on `/admin/*`) is unaffected.
- `BODY_SIZE_LIMIT` ("10M")
- `USER_PATH_HEADER` (`X-GoModel-User-Path`: Header used to read/write request `user_path` values)
- `PID_FILE` / `server.pid_file` (`data/gomodel.pid` next to a `./data` directory, otherwise the OS per-user data dir — same resolution as `SQLITE_PATH`): where the running gateway records its process id. `gomodel --reload` reads it and signals that process (SIGHUP; `kill -HUP` works too) to reload configuration without a restart, like `nginx -s reload`. The reload re-reads `.env` (exported variables still win over the file; variables removed from the file are unset) and the whole config, then rebuilds the application — so every setting reloads, not a curated subset. The replacement is built before the running one is stopped, so a broken config keeps the current one serving; the listening socket is held across generations, so no connection is refused mid-reload. `PORT` and `PID_FILE` changes still need a restart (warned about), and in-memory state — rate limit counters, session affinity pins, live log buffers — resets as it would on restart. `server.pid_file: ""` in `config.yaml` disables the pid file and `--reload` (an empty `PID_FILE` env var reads as unset and keeps the default). Not available on Windows (POSIX signals).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Qualify the socket-handoff guarantee.

run/socket.go rebinds the address when listener descriptor duplication fails. During that fallback, connections can be refused. State that the no-refusal guarantee applies only when descriptor duplication succeeds.

As per coding guidelines, “Document new configuration or API behavior and mention relevant provider-specific behavior.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` at line 114, Update the PID_FILE reload documentation to qualify
the listening-socket handoff guarantee: state that no connections are refused
only when listener descriptor duplication succeeds, and that the fallback rebind
may briefly refuse connections. Preserve the existing reload behavior and
platform details.

Source: Coding guidelines

- `ENABLE_PASSTHROUGH_ROUTES` (true: Enable provider-native passthrough routes under /p/{provider}/...)
- `ALLOW_PASSTHROUGH_V1_ALIAS` (true: Allow /p/{provider}/v1/... aliases while keeping /p/{provider}/... canonical)
- `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,zai,vllm: Comma-separated list of enabled passthrough providers)
Expand Down
1 change: 1 addition & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ server:
user_path_header: "X-GoModel-User-Path" # env: USER_PATH_HEADER; inbound header used for user_path scoping
enabled_passthrough_providers: ["openai", "anthropic", "cohere", "openrouter", "kilo", "zai", "vllm", "deepseek", "bailian"] # providers enabled on /p/{provider}/...
realtime_enabled: true # env: REALTIME_ENABLED; expose /v1/realtime websocket and /p/{provider}/v1/realtime upgrades (OpenAI only)
pid_file: "data/gomodel.pid" # env: PID_FILE; where the running gateway records its process id so `gomodel --reload` can find it. Set per instance when several gateways share a host; empty writes no pid file and disables --reload; changing it needs a restart, not a reload

models:
enabled_by_default: true # env: MODELS_ENABLED_BY_DEFAULT; when false, models stay unavailable until an access override allows one or more user paths
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func buildDefaultConfig() *Config {
UserPathHeader: "X-GoModel-User-Path",
SwaggerEnabled: false,
PprofEnabled: false,
PIDFile: DefaultPIDFilePath(),
EnablePassthroughRoutes: true,
AllowPassthroughV1Alias: true,
RealtimeEnabled: true,
Expand Down
22 changes: 22 additions & 0 deletions config/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"regexp"
"strconv"
"strings"

"github.com/enterpilot/gomodel/internal/platformdir"
)

// Body size limit constants
Expand Down Expand Up @@ -43,6 +45,26 @@ type ServerConfig struct {
// at /v1/realtime and the /p/{provider}/v1/realtime passthrough upgrade.
// Default: true. Only providers implementing realtime accept sessions.
RealtimeEnabled bool `yaml:"realtime_enabled" env:"REALTIME_ENABLED"`
// PIDFile records the process id of the running gateway so `gomodel --reload`
// can find it. Default: DefaultPIDFilePath(). Set it per instance when
// several gateways share a host, or to "" in config.yaml to write no pid
// file at all, which also disables `--reload` (an empty PID_FILE reads as
// unset, like every other env var here, and keeps the default). Changing it
// needs a restart — it names the process that is already running — so a
// reload only warns about it.
PIDFile string `yaml:"pid_file" env:"PID_FILE"`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// LegacyPIDFilePath is the pid file location used next to a project-local
// ./data directory, matching where the SQLite database lands in the same setup.
const LegacyPIDFilePath = "data/gomodel.pid"

// DefaultPIDFilePath returns the pid file path used when none is configured:
// LegacyPIDFilePath when a ./data directory already exists (Docker images and
// existing deployments), otherwise the OS-conventional per-user data directory
// — the same resolution the database uses, so both land together.
func DefaultPIDFilePath() string {
return platformdir.DataFile("gomodel.pid")
}
Comment on lines +62 to 68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare DefaultPIDFilePath with storage.DefaultSQLitePath for duplicated logic.
fd -t f storage.go | xargs -I{} rg -n -A 12 'func DefaultSQLitePath' {}

Repository: ENTERPILOT/GoModel

Length of output: 509


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
fd -t f '(^storage\.go$|server\.go$)'
echo

echo "== config/server.go relevant section =="
cat -n config/server.go | sed -n '1,100p'
echo

echo "== storage storage.go relevant section =="
cat -n storage.go | sed -n '1,70p'
echo

echo "== platformdir DataDir and relevant symbols =="
fd -t f 'platformdir' -d .
for f in $(fd -t f 'platformdir' -d .); do
  echo "--- $f"
  cat -n "$f" | sed -n '1,120p'
done

Repository: ENTERPILOT/GoModel

Length of output: 5536


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== config/storage.go =="
cat -n config/storage.go | sed -n '1,90p'
echo

echo "== internal/storage/storage.go relevant section =="
cat -n internal/storage/storage.go | sed -n '1,80p'
echo

echo "== platformdir files =="
fd -t f 'platformdir' . -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,140p"' sh {}

Repository: ENTERPILOT/GoModel

Length of output: 10263


Extract the shared data-directory fallback.

DefaultSQLitePath() and DefaultPIDFilePath() use the same resolution rule: return the legacy path when ./data exists, otherwise call platformdir.DataDir() and fall back to the legacy path on error. Move this resolution to one helper, such as in internal/platformdir, so future changes keep both paths in sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/server.go` around lines 61 - 74, Extract the shared data-directory
resolution used by DefaultSQLitePath and DefaultPIDFilePath into a helper in
internal/platformdir, preserving the rule that an existing ./data directory or a
DataDir error selects the legacy path. Update both functions to call this helper
and append their respective filenames, keeping the existing path values and
behavior unchanged.


var headerNameRegex = regexp.MustCompile(`^[!#$%&'*+\-.^_` + "`" + `|~0-9A-Za-z]+$`)
Expand Down
102 changes: 102 additions & 0 deletions config/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package config

import (
"os"
"path/filepath"
"testing"

"github.com/enterpilot/gomodel/internal/platformdir"
)

// The pid file follows the database instead of scattering GoModel's state
// across the filesystem: a Docker image with /app/data keeps both
// project-local, and a binary install started from an arbitrary working
// directory keeps both in the per-user data directory.
func TestDefaultPIDFilePath(t *testing.T) {
platformDataDir, err := platformdir.DataDir()
if err != nil {
t.Fatalf("platformdir.DataDir() error: %v", err)
}

tests := []struct {
name string
setup func(t *testing.T, dir string)
want string
}{
{
name: "data directory exists keeps the project-local path",
setup: func(t *testing.T, dir string) {
if err := os.Mkdir(filepath.Join(dir, "data"), 0o755); err != nil {
t.Fatal(err)
}
},
want: LegacyPIDFilePath,
},
{
name: "no data directory uses the platform path",
setup: func(t *testing.T, dir string) {},
want: filepath.Join(platformDataDir, "gomodel.pid"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
tt.setup(t, dir)
t.Chdir(dir)

if got := DefaultPIDFilePath(); got != tt.want {
t.Errorf("DefaultPIDFilePath() = %q, want %q", got, tt.want)
}
})
}
}

func TestPIDFilePathResolution(t *testing.T) {
tests := []struct {
name string
env string
configYAML string
want string
}{
{
name: "env var wins",
env: "/var/run/gomodel/custom.pid",
want: "/var/run/gomodel/custom.pid",
},
{
// Empty env vars are "unset" everywhere in this config, so PID_FILE=
// keeps the default rather than disabling the pid file. Asserted so
// the documented way to disable it stays the config file.
name: "empty env var keeps the default",
env: "",
want: DefaultPIDFilePath(),
},
{
name: "empty config value writes no pid file",
configYAML: "server:\n pid_file: \"\"\n",
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
t.Setenv("PID_FILE", tt.env)
if tt.configYAML != "" {
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(tt.configYAML), 0o600); err != nil {
t.Fatal(err)
}
}

result, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got := result.Config.Server.PIDFile; got != tt.want {
t.Errorf("Server.PIDFile = %q, want %q", got, tt.want)
}
})
}
}
95 changes: 93 additions & 2 deletions docs/advanced/cli.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: "CLI Operations"
description: "Command-line flags for inspecting the GoModel binary and probing a running gateway's health."
description: "Command-line flags for inspecting the GoModel binary, probing a running gateway's health, and reloading its configuration."
icon: "terminal"
keywords: ["CLI", "command-line flags", "health check", "version probe"]
keywords: ["CLI", "command-line flags", "health check", "version probe", "config reload", "SIGHUP"]
---

## Overview
Expand All @@ -18,6 +18,7 @@ the examples below use the long form.
| `--health-timeout` | Maximum time to wait for the `--health` probe | `2s` |
| `--ready` | Probe the local `/health/ready` (readiness) endpoint and exit | — |
| `--ready-timeout` | Maximum time to wait for the `--ready` probe | `4s` |
| `--reload` | Tell the running gateway to reload its configuration and exit | — |

## Version

Expand Down Expand Up @@ -90,6 +91,96 @@ gomodel --ready
{ "status": "ready", "components": { "storage": "ok", "cache": "ok" } }
```

## Configuration reload

`--reload` applies configuration changes to a running gateway without
restarting it — the same operation as `nginx -s reload`:

```bash
gomodel --reload
```

It loads the same configuration the gateway does to find the pid file, signals
that process, and exits. Loading it first means a `config.yaml` the binary
cannot parse fails the command — non-zero exit, no signal sent — before the
running gateway is ever asked to look at it. On success the gateway:

1. Re-reads the `.env` file. New and edited values are applied; values removed
from the file are unset. Variables exported into the process environment
keep winning over the file, exactly as they do at startup — a container's
environment is not overridden by a file inside it.
2. Re-reads `config/config.yaml` (or `config.yaml`) and every environment
variable, then rebuilds itself from the result. Providers, virtual models,
budgets, rate limits, guardrails, MCP servers, caching, logging, admin
settings — all of it reloads, because the reload re-runs the same startup
path rather than a hand-picked subset.

The replacement is built **before** the running configuration is stopped, so a
configuration that fails to load or initialize changes nothing: the gateway logs
`reload failed; keeping the running configuration` and keeps serving on what
already works.

The listening socket is held for the lifetime of the process and handed to each
configuration in turn, so requests arriving mid-reload wait to be accepted
rather than being refused. In-flight requests get the same 10-second drain
window as a shutdown, and streamed responses that outlive it are cut, so a
reload during heavy streaming traffic is not free — but no connection is dropped
at the socket.

That last guarantee depends on the operating system letting the gateway
duplicate a listening socket, which covers every platform that can be sent a
reload signal in the first place. Where duplication is unavailable, the next
configuration rebinds the address instead, and connections are refused for the
length of the swap.

Sending the signal directly does the same thing, which is what a process manager
or a container without a shell can use:

```bash
kill -HUP "$(cat data/gomodel.pid)"
```

<Note>
Reload is a POSIX signal feature and is not available on Windows.
</Note>

### The pid file

The gateway writes its process id to `PID_FILE` / `server.pid_file` at startup
and removes it on shutdown. The default is `data/gomodel.pid` when a `./data`
directory exists (Docker images and existing deployments) and the
OS-conventional per-user data directory otherwise — the same resolution the
SQLite database uses.

Give each instance its own path when several gateways share a host, since
`--reload` signals whichever process the file names. To write no pid file at
all, set `server.pid_file: ""` in `config.yaml` — that disables `--reload`,
though `kill -HUP` still works. An empty `PID_FILE` env var reads as unset and
keeps the default, the same as every other setting. If the path is not writable
the gateway logs a warning and serves normally, only without `--reload` support.

Both the gateway and `gomodel --reload` resolve the path from the same
configuration, so run the command from the same working directory (or with the
same `PID_FILE`) as the gateway:

```bash
docker exec my-gateway /gomodel --reload
```

### What a reload does not change

- **`PORT`** — the socket stays bound so no connection is refused; a port change
needs a restart. The gateway logs a warning naming both ports.
- **`PID_FILE`** — it names the process that is already running.
- **`GOMODEL_DEMO_MODE`** — the demo warnings are wired up once at startup.
- **In-memory state** — rate limit counters, virtual-model session affinity, and
live log buffers start fresh, exactly as they would after a restart. Budgets
and usage are stored in the database and are unaffected.

For refreshing provider model catalogs and admin-managed data *without* re-reading
configuration, the dashboard's runtime refresh (`POST /admin/runtime/refresh`) is
the lighter option.

Liveness (`--health`) is the right signal for a Docker `HEALTHCHECK` (restart on
crash). Readiness (`/health/ready`) is the right signal for a Kubernetes
`readinessProbe` (gate traffic) — point it at the HTTP endpoint directly or run
Expand Down
7 changes: 7 additions & 0 deletions docs/advanced/config-yaml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ For deployments mounted below a domain root, set `server.base_path` or
To change the inbound user path header, set `server.user_path_header` or
`USER_PATH_HEADER`. The default remains `X-GoModel-User-Path`.

Edits to this file are applied to a running gateway with `gomodel --reload`,
which re-reads the file and the environment and rebuilds the gateway in place —
see [CLI Operations](/advanced/cli#configuration-reload). Nothing changes if the
new file fails to load. `server.port` and `server.pid_file` are the exceptions:
both are fixed for the life of the process, so a reload logs a warning and keeps
the running values until the gateway is restarted.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Docker

GoModel reads `config/config.yaml` first, then `config.yaml`.
Expand Down
1 change: 1 addition & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The most common way to configure GoModel. Set any of the variables below to over
| `GOMODEL_DEMO_MODE` | Enable public demo warnings in logs and the dashboard | `false` |
| `BODY_SIZE_LIMIT` | Max request body size (e.g., `10M`, `1024K`, `500KB`) | _(no limit)_ |
| `USER_PATH_HEADER` | Header used to read/write request `user_path` values | `X-GoModel-User-Path` |
| `PID_FILE` | Where the running gateway records its process id for `gomodel --reload`. Changing it needs a restart | `data/gomodel.pid` next to a `./data` directory, otherwise the per-user data directory |

Set `GOMODEL_DEMO_MODE=true` for a public or shared demonstration instance.
GoModel logs a warning at startup and every five minutes, renders a persistent
Expand Down
26 changes: 26 additions & 0 deletions internal/platformdir/platformdir.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ func DataDir() (string, error) {
}
}

// LocalDataDir is the project-local data directory. Deployments that already
// have one — the Docker image, anyone running from a checkout — keep their
// state there, so upgrades never move a database or a pid file out from under
// an operator.
const LocalDataDir = "data"

// DataFile returns where a durable data file called name belongs: inside
// LocalDataDir when that directory exists next to the process, otherwise inside
// DataDir. Callers share this rule so a deployment's files stay together
// instead of some landing project-local and others in the per-user directory.
//
// The local form is spelled with a forward slash on every platform, which
// Windows accepts, so it stays comparable to the legacy path constants built
// the same way.
func DataFile(name string) string {
local := LocalDataDir + "/" + name
if info, err := os.Stat(LocalDataDir); err == nil && info.IsDir() {
return local
}
dir, err := DataDir()
if err != nil {
return local
}
return filepath.Join(dir, name)
}

// CacheDir returns the directory for re-creatable caches such as the model
// catalog:
//
Expand Down
22 changes: 16 additions & 6 deletions internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,14 +538,16 @@ func (s *Server) Start(ctx context.Context, addr string) error {
return newGatewayStartConfig(addr).Start(ctx, s.echo)
}

// StartWithListener starts the HTTP server using a pre-bound listener.
// This is useful in tests that need an already-reserved loopback port.
// StartWithListener starts the HTTP server using a pre-bound listener. The
// gateway serves this way in production — the listening socket outlives each
// configuration a reload installs — and tests use it to reserve a loopback
// port up front, so it configures the server exactly like Start does: same
// inbound timeouts, same drain window.
func (s *Server) StartWithListener(ctx context.Context, listener net.Listener) error {
sc := echo.StartConfig{
HideBanner: true,
Listener: listener,
if listener == nil {
return errors.New("listener is required")
}
return sc.Start(ctx, s.echo)
return newGatewayStartConfigForListener(listener).Start(ctx, s.echo)
}

// Shutdown releases server resources. The HTTP server itself is stopped by
Expand Down Expand Up @@ -606,6 +608,14 @@ func newGatewayStartConfig(addr string) echo.StartConfig {
}
}

// newGatewayStartConfig with a pre-bound listener. Echo ignores Address once
// Listener is set; it is filled in anyway so the two describe the same server.
func newGatewayStartConfigForListener(listener net.Listener) echo.StartConfig {
sc := newGatewayStartConfig(listener.Addr().String())
sc.Listener = listener
return sc
}

func configureGatewayHTTPServer(server *http.Server) error {
if server == nil {
return nil
Expand Down
Loading