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
5 changes: 5 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@
# SEMANTIC_CACHE_WEAVIATE_API_KEY=

# Optional: Custom cache directory for local file cache
# Default: ./.cache when it already exists, otherwise the OS per-user cache
# directory (~/.cache/gomodel, ~/Library/Caches/gomodel, %LocalAppData%\gomodel\cache)
# GOMODEL_CACHE_DIR=.cache

# External model metadata registry (provides pricing, capabilities, context window, etc.)
Expand Down Expand Up @@ -251,6 +253,9 @@
# STORAGE_TYPE=sqlite

# SQLite Configuration (default, good for single instance)
# Default: ./data/gomodel.db when a ./data directory exists, otherwise the
# OS per-user data directory (~/.local/share/gomodel, ~/Library/Application
# Support/gomodel, %LocalAppData%\gomodel)
# SQLITE_PATH=data/gomodel.db

# PostgreSQL Configuration (for multi-instance deployments)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Full reference: `.env.template` and `config/config.yaml`
- `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)
- `REALTIME_ENABLED` (true: Expose the realtime speech-to-speech websocket at `/v1/realtime` and the `/p/{provider}/v1/realtime` upgrade. The canonical `/v1/realtime` route needs only `REALTIME_ENABLED`; the `/p/{provider}/v1/realtime` upgrade additionally requires passthrough routes enabled (`ENABLE_PASSTHROUGH_ROUTES`) with the provider listed in `ENABLED_PASSTHROUGH_PROVIDERS`. The gateway is a transparent websocket reverse proxy — it injects provider credentials and relays the provider's realtime event schema verbatim (no translation), so clients connect without provider API keys. Only providers implementing realtime accept sessions. Currently: OpenAI and xAI/Grok Voice Agent (both `wss://…/v1/realtime`); Z.ai/Zhipu GLM-Realtime (`wss://…/api/paas/v4/realtime`); Bailian/Qwen-Omni (`wss://dashscope…/api-ws/v1/realtime`); and Azure OpenAI (`wss://<resource>/openai/realtime?api-version=…&deployment=…`, `api-key` header). All use OpenAI's realtime event schema (Z.ai adds extensions that relay transparently). Provider-specific notes: xAI voice models (e.g. `grok-voice-latest`) aren't in upstream `/models` discovery, so configure them via `XAI_MODELS`, and xAI bills realtime per-minute (no token usage reported); Azure realtime requires a realtime-capable `AZURE_API_VERSION` (the default may be too old) and the model selects the Azure deployment. (MiniMax was evaluated but skipped — its conversational realtime schema is not OpenAI-compatible.) Sessions are gated by the same model-access and budget rules as other model endpoints; usage is tracked per `response.done` event, accepting both the OpenAI singular and Alibaba plural token-detail spellings. The same flag also exposes the OpenAI-compatible WebRTC surface (via the optional `core.RealtimeCallProvider` interface — OpenAI and xAI at the shared `…/v1/realtime/{calls,client_secrets}` shape, and Azure OpenAI at its GA `<resource>/openai/v1/realtime/{calls,client_secrets}` surface with `api-key` auth and no api-version; xAI gates WebRTC calls per team, so unauthorized accounts get the upstream 403 relayed while client_secrets works. Bailian is deliberately not wired: its WebRTC is allowlist-only with a per-customer endpoint provided by sales, plus no call id in the answer; Z.ai has no WebRTC realtime): `POST /v1/realtime/calls` exchanges SDP (raw `application/sdp` offer with `?model=`, or multipart `sdp` + `session` JSON fields; the session/query model is rewritten to the resolved provider model so aliases and virtual models work) and relays the answer with a gateway-relative `Location: /v1/realtime/calls/{call_id}` header; `POST /v1/realtime/client_secrets` mints ephemeral browser credentials routed by `session.model` (falling back to the nested transcription model); and `GET /v1/realtime?call_id=…` attaches to an existing call as a sideband websocket (an in-memory per-instance call registry recalls the route for calls created through the same instance — 6h TTL, capped; otherwise pass explicit `model`+`provider` params). WebRTC media and events flow directly between client and provider, so after creating a call the gateway attaches its own best-effort sideband observer websocket to record usage per `response.done` (entries carry endpoint `/v1/realtime/calls`; skipped when usage tracking is off, and gateway-relayed sideband attaches for registry-known calls don't tap usage to avoid double counting). WebRTC signaling counts toward request-scoped rate limits, but concurrent-scope rules can't span a WebRTC call's lifetime since only signaling transits the gateway; ephemeral client secrets authenticate clients directly against the provider, so those sessions bypass the gateway entirely and are untracked.)
- **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (data/gomodel.db), `POSTGRES_URL`, `MONGODB_URL`. `/v1/responses` snapshots and `/v1/conversations` history persist to the configured backend (30-day TTL, hourly sweep); the in-memory fallback stores are byte-capped and used only by embedded setups that skip app wiring.
- **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (default: `data/gomodel.db` when a `./data` directory exists — existing deployments, Docker; otherwise the OS per-user data dir, e.g. `~/.local/share/gomodel/gomodel.db` — see `internal/platformdir`; the local model cache resolves `.cache` vs the OS cache dir the same way), `POSTGRES_URL`, `MONGODB_URL`. `/v1/responses` snapshots and `/v1/conversations` history persist to the configured backend (30-day TTL, hourly sweep); the in-memory fallback stores are byte-capped and used only by embedded setups that skip app wiring.
- **Models:** `MODELS_ENABLED_BY_DEFAULT` (true), `KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT` (false), `CONFIGURED_PROVIDER_MODELS_MODE` (`fallback` or `allowlist`, default `fallback`; `allowlist` skips upstream `/models` for providers with configured lists); persisted overrides restrict/allow selectors with `user_paths`. When alias-only models listing is enabled, `GET /v1/models` returns only model aliases, not full concrete model specs, to operators.
- **Virtual models:** Redirects (aliases / load balancers) and access policies are managed in the admin dashboard and persisted to the `virtual_models` store. A redirect with one target is a plain alias; a redirect with several targets is load balanced by `strategy`: `round_robin` (default; rotates across targets, honoring per-target `weight`) or `cost` (always routes to the cheapest catalog-priced available target, falling back to the first target when none are priced). Unavailable targets are skipped, so a redirect works while any target is live. Virtual models can also be declared as infrastructure-as-code under `virtual_models:` in `config.yaml` or via the `VIRTUAL_MODELS` env var (a JSON array; env merges over YAML, winning per `source`). Declarative entries are validated at startup, override admin-store rows with the same `source`, and are read-only in the dashboard. Startup validation is catalog-independent: structure plus explicit target `provider` names — a name matching no configured provider (a typo) aborts startup listing the registered providers; a name declared under `providers:` but unregistered (e.g. credentials unset in this environment) only warns and the target stays unavailable; target *model* availability is never a startup gate (checked at resolve time, since the catalog loads asynchronously).
- **MCP gateway:** `MCP_ENABLED` (true: expose the MCP-protocol endpoints; a no-op until servers are declared). GoModel aggregates upstream MCP (Model Context Protocol) servers behind the authenticated streamable-HTTP endpoint `/mcp` (POST JSON-RPC, GET notification stream, DELETE session end) and per-server endpoints `/mcp/{server}`. On `/mcp`, tools and prompts are namespaced `{server}_{name}` with deterministic ordering; `tools/call` accepts the namespaced name (longest server-prefix match) or a unique bare name; `/mcp/{server}` exposes original names. Tools, prompts, resources, and resource templates relay with raw schemas/results verbatim; upstream `instructions` are merged into the gateway's `initialize` result. Servers come from three sources with the usual precedence: `mcp.servers:` map in `config.yaml`, the `MCP_SERVERS` env var (JSON object merged over YAML per name), and the `mcp_servers` admin store (dashboard MCP Servers page / `/admin/mcp-servers` GET/PUT/DELETE + `POST .../{name}/reconnect` + `GET .../{name}/catalog` for the per-server tools/prompts/resources inspector); declarative entries are validated at startup, shadow same-name store rows, and are read-only in the dashboard (secret header values are redacted as `***` in admin reads, and a `***` value on PUT preserves the stored secret). Per-server fields: `url` + `transport` (`http` streamable default, `sse` legacy), or declarative-only `stdio` (`command`/`args`/`env` — rejected via admin API/dashboard because runtime-registered subprocesses would be an RCE vector), `headers` (upstream credentials, `${ENV}` supported; the gateway is a credential boundary — client bearer tokens are never forwarded upstream), `allowed_tools`/`disallowed_tools`, `user_paths` (visibility subtree scoping like virtual models — filtered out of `tools/list`, not just blocked at call time), `tool_timeout` (30s default). The `X-MCP-Servers` request header narrows a session to a comma-separated server subset. One upstream session is shared per server (lazy dial, redial-once on death); a failed listing marks the server `degraded` keeping its last catalog (stale carry-forward, 60s re-probe, 5m re-list, `list_changed` notifications trigger resync). Downstream sessions are SDK-managed (`Mcp-Session-Id`, 30m idle timeout), bound to the initializing user path (a different principal presenting the session ID gets 404), and each session sees a visibility-filtered tool snapshot taken at initialize. Every MCP POST is gated by user-path rate limits and budgets; every `tools/call` writes a usage entry (`provider="mcp"`, `provider_name`=server, `model`=namespaced tool, duration/sizes/error in raw data, labels/user_path as usual) and MCP paths are audit-logged model interactions whose entries are labelled with the JSON-RPC method (tool/prompt name for calls) and `provider="mcp"`, so request-log and live-log rows are self-describing; with `LOGGING_LOG_BODIES` the JSON-RPC request and response frames (SSE replies decoded) are captured on POST entries too. Server→client MCP features (sampling, elicitation, roots) and resource subscriptions are not negotiated in v1. Spec: `docs/dev/2026-07-07_mcp-gateway-spec.md`.
Expand Down
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func buildDefaultConfig() *Config {
Storage: StorageConfig{
Type: "sqlite",
SQLite: SQLiteStorageConfig{
Path: storage.DefaultSQLitePath,
Path: storage.DefaultSQLitePath(),
},
PostgreSQL: PostgreSQLStorageConfig{
MaxConns: 10,
Expand Down
6 changes: 4 additions & 2 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (

"gopkg.in/yaml.v3"
"time"

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

// clearProviderEnvVars unsets all known provider-related environment variables.
Expand Down Expand Up @@ -136,8 +138,8 @@ func TestBuildDefaultConfig(t *testing.T) {
if cfg.Storage.Type != "sqlite" {
t.Errorf("expected Storage.Type=sqlite, got %s", cfg.Storage.Type)
}
if cfg.Storage.SQLite.Path != "data/gomodel.db" {
t.Errorf("expected Storage.SQLite.Path=data/gomodel.db, got %s", cfg.Storage.SQLite.Path)
if cfg.Storage.SQLite.Path != storage.DefaultSQLitePath() {
t.Errorf("expected Storage.SQLite.Path=%s, got %s", storage.DefaultSQLitePath(), cfg.Storage.SQLite.Path)
}
if cfg.Storage.PostgreSQL.MaxConns != 10 {
t.Errorf("expected Storage.PostgreSQL.MaxConns=10, got %d", cfg.Storage.PostgreSQL.MaxConns)
Expand Down
6 changes: 4 additions & 2 deletions config/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ type StorageConfig struct {

// SQLiteStorageConfig holds SQLite-specific storage configuration
type SQLiteStorageConfig struct {
// Path is the database file path (default: data/gomodel.db)
// Path is the database file path. Default: ./data/gomodel.db when a
// ./data directory exists, otherwise the OS per-user data directory
// (e.g. ~/.local/share/gomodel/gomodel.db).
Path string `yaml:"path" env:"SQLITE_PATH"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Honor documented env var
.env.template now tells operators to set GOMODEL_SQLITE_PATH, but this tag still only reads SQLITE_PATH. With no YAML path, Load() ignores the documented variable and keeps the default path, so an explicit database location no longer wins as described by the PR and users can start against the wrong SQLite file.

Context Used: CLAUDE.md (source)

Artifacts

Repro: focused Go test exercising config.Load SQLite env override behavior

  • Contains supporting evidence from the run (text/x-go; charset=utf-8).

Repro: verbose go test output showing GOMODEL_SQLITE_PATH ignored and SQLITE_PATH honored

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

}

Expand Down Expand Up @@ -60,7 +62,7 @@ func (c StorageConfig) BackendConfig() storage.Config {
cfg.Type = storage.TypeSQLite
}
if cfg.SQLite.Path == "" {
cfg.SQLite.Path = storage.DefaultSQLitePath
cfg.SQLite.Path = storage.DefaultSQLitePath()
}
return cfg
}
6 changes: 5 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,11 @@ func (a *App) logStartupInfo() {
}

// Storage configuration (shared by audit logging and usage tracking)
slog.Info("storage configured", "type", cfg.Storage.Type)
if backend := cfg.Storage.BackendConfig(); backend.Type == storage.TypeSQLite {
slog.Info("storage configured", "type", backend.Type, "path", backend.SQLite.Path)
} else {
slog.Info("storage configured", "type", backend.Type)
}

// Audit logging configuration
if cfg.Logging.Enabled {
Expand Down
65 changes: 65 additions & 0 deletions internal/platformdir/platformdir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Package platformdir resolves the OS-conventional per-user directories for
// GoModel's durable data and caches, used when no explicit path is
// configured. Binary installs (install.sh / Homebrew / install.ps1) run from
// arbitrary working directories, so CWD-relative defaults would scatter
// state; these follow each platform's convention instead.
package platformdir

import (
"os"
"path/filepath"
"runtime"
)

const app = "gomodel"

// DataDir returns the directory for durable application data such as the
// SQLite database:
//
// Linux $XDG_DATA_HOME/gomodel (default ~/.local/share/gomodel)
// macOS ~/Library/Application Support/gomodel
// Windows %LocalAppData%\gomodel
func DataDir() (string, error) {
switch runtime.GOOS {
case "windows":
base, err := os.UserCacheDir() // %LocalAppData%
if err != nil {
return "", err
}
return filepath.Join(base, app), nil
case "darwin":
base, err := os.UserConfigDir() // ~/Library/Application Support
if err != nil {
return "", err
}
return filepath.Join(base, app), nil
default:
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, app), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", app), nil
}
}

// CacheDir returns the directory for re-creatable caches such as the model
// catalog:
//
// Linux $XDG_CACHE_HOME/gomodel (default ~/.cache/gomodel)
// macOS ~/Library/Caches/gomodel
// Windows %LocalAppData%\gomodel\cache
func CacheDir() (string, error) {
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
if runtime.GOOS == "windows" {
// UserCacheDir is %LocalAppData%, shared with DataDir: keep the
// cache in a subdirectory so the two stay distinguishable.
return filepath.Join(base, app, "cache"), nil
}
return filepath.Join(base, app), nil
}
66 changes: 66 additions & 0 deletions internal/platformdir/platformdir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package platformdir

import (
"path/filepath"
"runtime"
"testing"
)

func TestDataDir(t *testing.T) {
dir, err := DataDir()
if err != nil {
t.Fatalf("DataDir() error: %v", err)
}
if !filepath.IsAbs(dir) {
t.Errorf("DataDir() = %q, want an absolute path", dir)
}
if filepath.Base(dir) != app {
t.Errorf("DataDir() = %q, want a %q leaf directory", dir, app)
}
}

func TestDataDirHonorsXDGDataHome(t *testing.T) {
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
t.Skip("XDG_DATA_HOME only applies to the default (Linux/Unix) branch")
}
t.Setenv("XDG_DATA_HOME", "/custom/data")

dir, err := DataDir()
if err != nil {
t.Fatalf("DataDir() error: %v", err)
}
if want := filepath.Join("/custom/data", app); dir != want {
t.Errorf("DataDir() = %q, want %q", dir, want)
}
}

func TestCacheDir(t *testing.T) {
dir, err := CacheDir()
if err != nil {
t.Fatalf("CacheDir() error: %v", err)
}
if !filepath.IsAbs(dir) {
t.Errorf("CacheDir() = %q, want an absolute path", dir)
}
want := app
if runtime.GOOS == "windows" {
want = "cache"
}
if filepath.Base(dir) != want {
t.Errorf("CacheDir() = %q, want a %q leaf directory", dir, want)
}
}

func TestDataAndCacheDirsDiffer(t *testing.T) {
dataDir, err := DataDir()
if err != nil {
t.Fatalf("DataDir() error: %v", err)
}
cacheDir, err := CacheDir()
if err != nil {
t.Fatalf("CacheDir() error: %v", err)
}
if dataDir == cacheDir {
t.Errorf("DataDir() and CacheDir() are both %q; they must differ", dataDir)
}
}
18 changes: 17 additions & 1 deletion internal/providers/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"sync"
Expand All @@ -14,6 +15,7 @@ import (
"github.com/enterpilot/gomodel/internal/cache/modelcache"
"github.com/enterpilot/gomodel/internal/core"
"github.com/enterpilot/gomodel/internal/modeldata"
"github.com/enterpilot/gomodel/internal/platformdir"
)

// InitResult holds the initialized provider infrastructure and cleanup functions.
Expand Down Expand Up @@ -204,7 +206,7 @@ func initCache(cfg *config.Config) (modelcache.Cache, error) {
if m.Local != nil {
cacheDir := m.Local.CacheDir
if cacheDir == "" {
cacheDir = ".cache"
cacheDir = defaultModelCacheDir()
}
cacheFile := filepath.Join(cacheDir, "models.json")
slog.Info("using local file cache", "path", cacheFile)
Expand All @@ -213,6 +215,20 @@ func initCache(cfg *config.Config) (modelcache.Cache, error) {
return nil, fmt.Errorf("cache.model: must have either local or redis configured")
}

// defaultModelCacheDir keeps the historical ./.cache location whenever it
// already exists (existing deployments, the Docker image); fresh binary
// installs get the OS-conventional per-user cache directory instead.
func defaultModelCacheDir() string {
if info, err := os.Stat(".cache"); err == nil && info.IsDir() {
return ".cache"
}
dir, err := platformdir.CacheDir()
if err != nil {
return ".cache"
}
return dir
}

// initializeProviders instantiates and registers all resolved providers.
// Returns the count of successfully registered providers.
func initializeProviders(ctx context.Context, providerMap map[string]ProviderConfig, factory *ProviderFactory, registry *ModelRegistry) (int, error) {
Expand Down
59 changes: 59 additions & 0 deletions internal/storage/default_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package storage

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

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

func TestDefaultSQLitePath(t *testing.T) {
platformDataDir, err := platformdir.DataDir()
if err != nil {
t.Fatalf("platformdir.DataDir() error: %v", err)
}
platformPath := filepath.Join(platformDataDir, "gomodel.db")

tests := []struct {
name string
setup func(t *testing.T, dir string)
want string
}{
{
name: "data directory exists keeps legacy path",
setup: func(t *testing.T, dir string) {
if err := os.Mkdir(filepath.Join(dir, "data"), 0o755); err != nil {
t.Fatal(err)
}
},
want: LegacySQLitePath,
},
{
name: "regular file named data falls through to platform path",
setup: func(t *testing.T, dir string) {
if err := os.WriteFile(filepath.Join(dir, "data"), []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
},
want: platformPath,
},
{
name: "no data entry uses platform path",
setup: func(t *testing.T, dir string) {},
want: platformPath,
},
}

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

if got := DefaultSQLitePath(); got != tt.want {
t.Errorf("DefaultSQLitePath() = %q, want %q", got, tt.want)
}
})
}
}
2 changes: 1 addition & 1 deletion internal/storage/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type sqliteStorage struct {
// It enables WAL mode for better concurrent read/write performance.
func NewSQLite(cfg SQLiteConfig) (SQLiteStorage, error) {
if cfg.Path == "" {
cfg.Path = DefaultSQLitePath
cfg.Path = DefaultSQLitePath()
}

// Ensure directory exists
Expand Down
Loading