diff --git a/.env.template b/.env.template index f00784cd..95e898ad 100644 --- a/.env.template +++ b/.env.template @@ -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.) @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index 4e35afdd..98530eb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:///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 `/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`. diff --git a/config/config.go b/config/config.go index 9e6b0c2f..8819cb90 100644 --- a/config/config.go +++ b/config/config.go @@ -93,7 +93,7 @@ func buildDefaultConfig() *Config { Storage: StorageConfig{ Type: "sqlite", SQLite: SQLiteStorageConfig{ - Path: storage.DefaultSQLitePath, + Path: storage.DefaultSQLitePath(), }, PostgreSQL: PostgreSQLStorageConfig{ MaxConns: 10, diff --git a/config/config_test.go b/config/config_test.go index 51d2102a..49ea3528 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -9,6 +9,8 @@ import ( "gopkg.in/yaml.v3" "time" + + "github.com/enterpilot/gomodel/internal/storage" ) // clearProviderEnvVars unsets all known provider-related environment variables. @@ -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) diff --git a/config/storage.go b/config/storage.go index 67e62a61..21759dad 100644 --- a/config/storage.go +++ b/config/storage.go @@ -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"` } @@ -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 } diff --git a/internal/app/app.go b/internal/app/app.go index f622f680..b35df687 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/platformdir/platformdir.go b/internal/platformdir/platformdir.go new file mode 100644 index 00000000..a2a6c390 --- /dev/null +++ b/internal/platformdir/platformdir.go @@ -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 +} diff --git a/internal/platformdir/platformdir_test.go b/internal/platformdir/platformdir_test.go new file mode 100644 index 00000000..bd4ab471 --- /dev/null +++ b/internal/platformdir/platformdir_test.go @@ -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) + } +} diff --git a/internal/providers/init.go b/internal/providers/init.go index 0e80d0ad..8d31a9c1 100644 --- a/internal/providers/init.go +++ b/internal/providers/init.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os" "path/filepath" "sort" "sync" @@ -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. @@ -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) @@ -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) { diff --git a/internal/storage/default_path_test.go b/internal/storage/default_path_test.go new file mode 100644 index 00000000..f0e501ac --- /dev/null +++ b/internal/storage/default_path_test.go @@ -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) + } + }) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index ac44c587..4df33254 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -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 diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 43ab1931..b23c9378 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -7,9 +7,13 @@ import ( "context" "database/sql" "fmt" + "os" + "path/filepath" "github.com/jackc/pgx/v5/pgxpool" "go.mongodb.org/mongo-driver/v2/mongo" + + "github.com/enterpilot/gomodel/internal/platformdir" ) // Type constants for storage backends @@ -19,8 +23,25 @@ const ( TypeMongoDB = "mongodb" ) -// DefaultSQLitePath is the default file path for the SQLite database. -const DefaultSQLitePath = "data/gomodel.db" +// LegacySQLitePath is the historical default database location, relative to +// the working directory. It stays the default whenever a ./data directory +// exists (existing deployments, the Docker image), so upgrades never move +// anyone's database. +const LegacySQLitePath = "data/gomodel.db" + +// DefaultSQLitePath returns the database path used when none is configured: +// LegacySQLitePath when a ./data directory already exists, otherwise the +// OS-conventional per-user data directory (see platformdir.DataDir). +func DefaultSQLitePath() string { + if info, err := os.Stat("data"); err == nil && info.IsDir() { + return LegacySQLitePath + } + dir, err := platformdir.DataDir() + if err != nil { + return LegacySQLitePath + } + return filepath.Join(dir, "gomodel.db") +} // Config holds storage configuration type Config struct { @@ -39,7 +60,7 @@ type Config struct { // SQLiteConfig holds SQLite-specific configuration type SQLiteConfig struct { - // Path is the database file path (default: data/gomodel.db) + // Path is the database file path (default: DefaultSQLitePath()) Path string }